fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one - #3617

Merged
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume
Jul 20, 2026
Merged

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one#3617
juliusmarminge merged 10 commits into
pingdotgg:mainfrom
vdmkotai:fix/3604-opencode-session-resume

Conversation

@vdmkotai

@vdmkotaivdmkotai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#3604.

image

What

The OpenCode adapter never read or emitted a resume cursor, so the upstream ses_… id lived only in process memory. When that in-memory binding was lost — ProviderSessionReaper stopping an idle session (~30 min), or an app/server restart — the next follow-up in the same visible thread was sent to a brand-new, empty OpenCode session. t3code kept rendering its own projection DB, so the user still saw the full history while the model had no context.

This makes the OpenCode adapter resumable, mirroring the existing Grok/Cursor/Codex pattern, entirely within apps/server/src/provider/Layers/OpenCodeAdapter.ts:

  • startSession now emits resumeCursor: { schemaVersion, sessionId } on the returned ProviderSession (and sendTurn echoes it), so ProviderService persists it into provider_session_runtime.resume_cursor_json.
  • When a cursor is present, startSession validates the id with session.get and re-adopts that session instead of calling session.create. OpenCode scopes history by session id, so prompting the same id restores the full prior conversation.
  • A missing/closed session (or any session.get failure) falls back to a fresh session, so a stale cursor can never wedge the thread.
  • The start race-cleanup only aborts the upstream session when we actually created it — never one we merely resumed.

Why

This is the documented root cause in #3604 (with DB-level evidence of two OpenCode sessions per visible thread). The persistence/recovery plumbing is already provider-agnostic: ProviderService.startSession falls back to the stored binding cursor when the reactor passes none, so no changes are needed outside the adapter — it just needed the adapter to start producing and consuming a cursor like every other provider already does.

Scope

Intentionally small and focused — 2 files, no contract / persistence / orchestration changes:

  • apps/server/src/provider/Layers/OpenCodeAdapter.ts (+118/-20)
  • apps/server/src/provider/Layers/OpenCodeAdapter.test.ts (+147) — regression tests for: fresh-session cursor emission, resume re-adopting the persisted id (no create), follow-up turns targeting the resumed id, stale-cursor fallback to create, and malformed/foreign-cursor rejection.

Validation

  • tsgo --noEmit clean; OpenCodeAdapter.test.ts (21 tests) plus the full src/provider + src/orchestration suites (531 tests) pass.
  • Ran a desktop build carrying this fix for a full day across many OpenCode sessions, including idle-past-reaper and app-restart between turns — every follow-up retained full context, no regressions observed.

Note

Medium Risk
Changes core session lifecycle for OpenCode threads; misclassified errors could still wedge or reset context, but behavior is guarded by structured 404 detection and extensive adapter tests.

Overview
Fixes #3604 by making the OpenCode adapter produce and consume a persisted resumeCursor (schemaVersion + sessionId), like other providers, so follow-ups keep upstream conversation context after idle reaper or restart.

startSession now probes session.get when a valid cursor is present: reuse the session when cwd matches (with session.update for current runtimeMode permissions), fork into the requested directory when cwd changed (preserving history), or create only on a confirmed 404. Transient/auth errors from the probe fail instead of silently starting fresh. Race cleanup aborts only sessions this call created, not resumed ones.

sendTurn returns the cursor so persistence stays fresh. Helpers isOpenCodeNotFound and isSameOpenCodeDirectory classify SDK errors and path equivalence (symlinks, trailing slashes).

Tests extend the OpenCode mock with get/update/fork and cover resume, stale cursor, bad cursor, transient errors, cwd fork, and utility behavior.

Reviewed by Cursor Bugbot for commit 8bfeff6. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Resume existing OpenCode sessions on follow-up turns instead of creating empty ones

  • startSession in OpenCodeAdapter.ts now parses a persisted resumeCursor and probes session.get to verify the session still exists before reusing it.
  • If the session's working directory differs from the requested cwd, the session is forked into the new directory; permissions are re-applied via session.update on resume.
  • Non-404 probe errors are propagated as failures rather than silently falling back to a new session; confirmed 404s fall back to creating a fresh session.
  • sendTurn results now include the session's resumeCursor so callers can persist it for future follow-ups.
  • Risk: sessions with a stale or wrong-version cursor emit a warning and fall back to a new session, discarding any prior conversation context.

Macroscope summarized 8bfeff6.

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28d89c9f-23bf-4ef7-90eb-4efb0c48336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@macroscopeapp

ghost commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces substantial new runtime behavior: session resumption via durable cursors, session forking when directories change, and new external API calls (session.get/update/fork). While well-tested, these changes fundamentally alter how OpenCode sessions are managed and warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jun 30, 2026
@vdmkotai

ghost commented Jun 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews — addressed in 7be6629. Three hardening changes, all kept within the adapter:

  1. Permission re-application on resume (Cursor Bugbot): session.create was the only place buildOpenCodePermissionRules(runtimeMode) was applied, so re-adopting a session via session.get left it on its original permissions — and ProviderCommandReactor restarts with the persisted cursor on a runtime-mode change. Resume now calls session.update({ sessionID, permission }), so a runtime-mode change takes effect on the re-adopted session.

  2. Confirmed-not-found vs. transient errors (macroscope): the SDK client is created with throwOnError: true, so session.get rejects on any non-2xx. The fallback to a fresh session now fires only on a confirmed 404 / NotFoundError; transport/auth/server errors propagate instead of silently resetting a live thread to an empty session (matching [Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding) #3604's own "surface an explicit error" suggestion).

  3. Directory-aware resume: OpenCode routes a prompt to the session's own stored directory, so resuming a session created under a different cwd would silently run there. Resume now starts a fresh session when the re-adopted session's directory differs from the requested cwd.

Tests updated to model throwOnError: true (get rejects, not a result tuple) and add coverage for permission re-application, the cwd-mismatch fallback, and transient-error propagation. Full server provider + orchestration suite green (534 passing).

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts Outdated
@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

UPDATE:
Seems like this fix is not enough. Making more changes

@vdmkotai

ghost commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Pushed a follow-up correcting the directory-mismatch handling that was added during hardening.

Problem: the guard that started a fresh session when the resumed session's stored directory differed from the requested cwd was justified by "OpenCode routes a prompt to the session's own stored directory." That premise doesn't hold — OpenCode resolves tool execution, snapshots and file ops from the per-request directory param (instance context), not session.info.directory. So on any cwd change (most commonly a thread moving from the project root into a git worktree between turns) the whole conversation was stranded in the old session and the follow-up landed in an empty one — reproducing the exact #3604 symptom this PR fixes. I hit this in real use with a worktree-backed thread (turn 1 created the session in the project root before the worktree metadata settled; turn 2 requested the worktree cwd → empty session).

Fix: when the persisted session exists but was created under a different directory, client.session.fork({ sessionID, directory }) into the requested directory instead of creating an empty session. OpenCode clones the full history into a new session bound to the requested worktree, so the follow-up keeps context and tools still run on the correct tree. The fork id becomes the durable resume cursor. A genuinely missing (404) session still starts fresh.

Verified against the OpenCode source that fork copies all messages/parts with fresh ids and stamps the new session's directory/path from the request context. The former "starts fresh on directory mismatch" test now asserts fork-with-history.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
@vdmkotai

ghost commented Jul 19, 2026

Copy link
Copy Markdown
ContributorAuthor

The single issue from Bugbot's Jul 19 review (raw string comparison of the resume directory against the session's stored directory) is fixed in 5741a3b and the inline thread is resolved: isSameOpenCodeDirectory now compares lexically resolved forms first and falls back to realpath on both sides (per-side lexical fallback when resolution fails), so a trailing slash or a symlinked cwd (macOS /tmp/private/tmp) no longer spuriously forks the session or churns the durable cursor. Covered by an adapter-level regression test (slash-only difference → reused in place, no fork/create) and direct unit tests of the helper including a real symlink fixture. tsgo clean, full src/provider suite green.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 061be07. Configure here.

Comment threadapps/server/src/provider/Layers/OpenCodeAdapter.ts
Vadym Kotaiand others added 10 commits July 20, 2026 12:02
…tarting an empty one
The OpenCode adapter always called session.create and never read or
emitted a resume cursor, so the upstream ses_… id lived only in memory.
When that in-memory binding was lost — the ProviderSessionReaper stopping
an idle session (~30 min) or an app/server restart — the next follow-up
in the same visible thread was sent to a brand-new, empty OpenCode
session. t3code kept rendering its own projection DB, so the user still
saw the full history while the model had no context (issue pingdotgg#3604).
Mirror the Grok/Cursor/Codex resume pattern, entirely within the adapter:
- Emit resumeCursor { schemaVersion, sessionId } on the started
ProviderSession (and echo it from sendTurn) so ProviderService persists
it into provider_session_runtime.resume_cursor_json.
- On startSession, when a cursor is present, validate the id with
session.get and re-adopt that session instead of creating a new one.
OpenCode scopes history by session id, so prompting the same id
restores the full prior conversation. A missing/closed session (or any
get failure) falls back to a fresh session so a stale cursor can't wedge
the thread.
- Only abort the upstream session in the start race-cleanup when we
actually created it; never abort a session we merely resumed.
The persistence/recovery plumbing is provider-agnostic and already feeds
a persisted cursor back into startSession on a reaped/restarted follow-up
(ProviderService falls back to the stored binding cursor when the reactor
passes none), so no changes outside the adapter are needed.
Adds regression tests: fresh-session cursor emission, resume re-adopting
the persisted id (no create), follow-up turns targeting the resumed id,
stale-cursor fallback to create, and malformed-cursor rejection.
Fixespingdotgg#3604
Co-authored-by: codex <codex@users.noreply.github.com>
…ass)
Addresses review feedback on pingdotgg#3617 (macroscope + Cursor Bugbot + a deep
multi-agent review) without widening scope beyond the adapter:
- Re-apply permissions on resume. `session.create` is the only place the
runtimeMode permission ruleset is set, so re-adopting a session skipped
it; the reactor restarts with the persisted cursor on a runtime-mode
change, which would leave a resumed OpenCode session on stale (e.g.
full-access) permissions. Resume now calls session.update with
buildOpenCodePermissionRules(runtimeMode).
- Don't resume into the wrong directory. OpenCode routes a prompt to the
session's own stored directory, so reusing a session created under a
different cwd would silently run there. Resume now starts a fresh
session when the re-adopted session's directory differs from the
requested cwd.
- Distinguish "not found" from transient failures. The SDK client uses
throwOnError:true, so session.get rejects on any non-2xx. Only a
confirmed 404 / NotFoundError now falls back to creating a fresh
session; transport/auth/server errors propagate instead of silently
resetting a live thread to an empty session.
Tests model throwOnError:true (session.get rejects) and add coverage for
the permission re-application, cwd-mismatch fallback, and transient-error
propagation paths.
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (Cursor Bugbot, high): isOpenCodeNotFound only walked the
`cause` chain checking a numeric `status`, so it relied on the 404 sitting
at one specific nesting and ignored `response.status` and the
OpenCodeRuntimeError `detail` string. Reworked it into a bounded BFS that
also checks `statusCode`, nested `response.status`, the NotFoundError
`name`/`body`, and `message`/`detail` text, descending cause/body/error/data.
Export it and add direct unit tests across every shape (incl. the real
wrapped-Error production shape and a response.status-only 404), plus the
transient/auth/network cases that must still propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…e text
Address review (Cursor Bugbot): isOpenCodeNotFound matched the free-text
message/detail, so a non-404 error whose text merely contains 'not found'
(a 500 saying 'upstream X not found', an auth error, or a serialized body
from openCodeRuntimeErrorDetail) was misclassified as a missing session and
silently started a fresh one. Decide only on structured signals — a numeric
404 (status/statusCode/nested response.status) or an explicit NotFoundError
name — which already cover the real throwOnError:true production shape
(cause.status=404 + body.name). Update unit tests to assert free-text-only
inputs (incl. a 500 whose message contains 'not found') now propagate.
Co-authored-by: codex <codex@users.noreply.github.com>
…ontext
The directory-mismatch guard added while hardening this PR started a fresh, EMPTY
session whenever the resumed session's stored directory differed from the requested
cwd. Its stated rationale -- "OpenCode routes a prompt to the session's own stored
directory, so resuming under a different cwd would run in the wrong tree" -- does not
hold: OpenCode resolves tool execution, snapshots and file ops from the per-request
directory param (instance context), not from session.info.directory. So the guard
solved a non-problem and introduced a real one: any time a thread's cwd changes (most
commonly when it moves from the project root into a git worktree between turns) the
whole conversation was stranded in the old session and the follow-up landed in an
empty one -- the exact pingdotgg#3604 symptom this PR set out to fix.
Fix: when the persisted session exists but was created under a different directory,
fork it INTO the requested directory (client.session.fork({ sessionID, directory }))
instead of creating an empty session. OpenCode clones the full message history into a
new session bound to the requested worktree, so the follow-up keeps its context and
tools still run on the correct tree. The fork id becomes the durable resume cursor. A
genuinely missing (404) session still starts fresh, unchanged.
Verified against the OpenCode source that fork copies all messages/parts with fresh
ids and stamps the new session's directory/path from the request context.
Test: the former "starts fresh on directory mismatch" case now asserts the session is
forked with history (fork called, no session.create, cursor -> fork id) via a new fork
mock; the not-found path still starts fresh.
Co-authored-by: codex <codex@users.noreply.github.com>
…ts name
A node carrying an explicit non-404 numeric HTTP status now seals its
subtree in isOpenCodeNotFound: a 500 whose serialized body is named
NotFoundError (or that is itself named UpstreamNotFoundError) propagates
instead of silently falling back to session.create and dropping context.
Co-authored-by: codex <codex@users.noreply.github.com>
A trailing slash, an unnormalized segment, or a symlinked cwd (macOS
/tmp -> /private/tmp) made the resume path misread the same working tree
as a cwd change, forking the session and repointing the durable cursor
at the clone on every resume. Compare lexically resolved forms first,
then realpath both sides, each degrading to its lexical form when
resolution fails (deleted directory, external-server path).
Co-authored-by: codex <codex@users.noreply.github.com>
…m/Path services
Drops the nodeBuiltinImport pragmas: isSameOpenCodeDirectory now takes
the FileSystem and Path services (resolved once in makeOpenCodeAdapter,
already present in OpenCodeDriverEnv) instead of importing node:fs and
node:path directly, and the symlink test fixture moves to
makeTempDirectoryScoped/symlink on the FileSystem service.
Co-authored-by: codex <codex@users.noreply.github.com>
Comment-only: the resume doc blocks had grown to 17-23 lines while
sibling adapters cap around 11; keep the constraints (structured-404-only
classification, subtree sealing, fork-preserves-history, race cleanup
scope) and drop the narration.
Co-authored-by: codex <codex@users.noreply.github.com>
A substring match let any status-less error named *NotFound*
(UpstreamNotFoundError, ProviderNotFoundError) pass as a missing
session and silently start an empty one. OpenCode's API generates
exactly name "NotFoundError" for every 404, so match it exactly.
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarmingeforce-pushed the fix/3604-opencode-session-resume branch from ea323c0 to 8bfeff6CompareJuly 20, 2026 10:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenCode provider loses thread context on follow-up — t3code starts a new OpenCode session instead of resuming (no durable session binding)

2 participants

@vdmkotai@juliusmarminge