Skip to content

fix: preserve opencode sub-agent session ids across folds via structured-field sidecar (#1702) - #1722

Merged
ranxianglei merged 3 commits into
masterfrom
2026-09-30_subagent-session-sidecar
Sep 30, 2026
Merged

ranxianglei merged 3 commits into
masterfrom
2026-09-30_subagent-session-sidecar

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Fixes #1702 — replaces #1704 per the review direction there (structured fields, not regex; sidecar + render-time attach, never polluting stored summaries; opencode only).

Root cause

The kernel's fold protection is keyed by tool name (ALWAYS_PROTECTED_TOOLS) and content-blind. Once a sub-agent dispatch pair slides out of the protected zone, the child ses_… session id survives only through the model's free-text summary — weak models drop it (#1563 A/B: ~20% loss), breaking later decompress-style retrieval of that sub-agent's context.

Design (per #1704 review)

  • Structured-field capture only (src/subagent-sessions.ts): the dispatch tool-call's arguments JSON is parsed and known id fields (sessionId / sessionID / session_id) are read under a strict ^ses_[A-Za-z0-9]+$ shape predicate. Prose is never regex-scanned.
  • Sidecar storage: session.metadata["subagentSessions"] — Record<blockId, string[]>. persist.ts already serializes the full metadata bag, so no persistence-format change.
  • Fold-commit sync (applyRanges): the same changed-block set that drives the [feature] compress 回执加摘要指纹 + inline decompress 回执给再折叠指引 #1294 fingerprint lines now also captures ids (covered view = effectiveMessageIds) and inherits child-block entries on T2 refolds (directBlockIds). Refolds whose re-capture comes up empty keep the prior entry (conservative retention); entries for blocks gone from state are pruned.
  • Model-visible carriers, byte-clean summaries:
    • compress receipt gains one line per changed block: [acp-subagent-sessions bN: ses_…] (plugin-mode carrier — the result rides the agent's re-sent history);
    • the four wire pipelines append the id line to the active block's rendered summary message after applyAbsorbView (proxy-mode carrier);
    • the stored b.summary stays byte-identical to the model payload — tier distillation never re-copies ids, and the attach lines are render-only.
  • opencode only: both capture and attach are gated on session.metadata.pluginAgent === "opencode". No other client lane ships a structured sub-agent field today; the module is reusable if one appears.

This intentionally deviates from the issue's literal acceptance ("id stored in the block") toward its spirit: the id is mechanically guaranteed model-visible after every fold, while summaries stay clean — direction agreed in the #1704 review thread.

Gate evidence

  • npm run typecheck ✓
  • npm run build ✓ (dist-import-annotations: OK)
  • new tests/subagent-sessions.test.ts 6/6 ✓ (capture variants + no-regex proof, sync gate/prune/inherit semantics, attach immutability, full applyRanges integration both opencode and non-opencode)
  • targeted suite (compress/stream/receipt/absorb/preflight) 143/143 ✓
  • full suite: failure set identical to the master baseline (omp-native / opencode-native / opencode-v2 / pi-native / plugin-agent — pre-existing local-env failures, confirmed via stash-baseline earlier; dsh-native excluded as it hangs locally on master too)

Diff: src/subagent-sessions.ts (new), src/stream.ts (+14/−2, fingerprint-loop reuse), src/server.ts (+5, import + 4 attach sites), tests/subagent-sessions.test.ts (new).

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-30_subagent-session-sidecar (b6fec1a)

Option A — Install this exact build (recommended)

npm install -g billion-context@0.0.1680-pr.1722.46b20f3

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr1722.tgz
npm install -g package

Install instructions are refreshed on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

Reviewed #1722 end-to-end (code, kernel/wire plumbing, local gates, CI). Verdict: mergeable — human merge per §7.4 (wire/message-shape change; auto-merge gate not applicable).

① Original issue (direction gate)

② Regression audit (pre-existing behaviors on touched paths)

Pre-existing behavior Conclusion
#1294 fingerprint receipt lines Preserved — the refactored loop keeps the identical predicate (prev === undefined || prev !== b.summary) and order/format; non-opencode receipts are byte-identical to master (code + integration test)
Compress receipt payload Additive only: one optional line per changed block carrying ids; stored b.summary byte-identical (test-pinned)
Outbound wire (all four prepare pipelines) Text-only extension of active-block summary messages; ids/roles/ordering/tool structure untouched; systemToUser re-voicing unaffected
Prefix-cache stability The attach line appears exactly when the summary text itself is born (first fold commit) and stays byte-stable under conservative retention/pruning → no added churn
Persistence format Unchanged — src/persist.ts:681 already serializes the whole metadata bag; the sidecar rides along, no schema/version change
Non-opencode lanes Zero change — both hooks early-return unless pluginAgent === "opencode" (test-pinned)
Tag-echo / marker stripping No interaction — the new line is ASCII-bracket like the #1294 fingerprint lines; MARKER_LINE only strips symbol-led [ACP] lines
Kernel id contract, nudge cadence, config surface Untouched (no new config fields — the owner-gated surface is respected; opencode-only is hard-gated per review direction)

No unintended regressions found; high-risk patterns (fail-fast → swallow, threshold/default drift, semantic side-change while fixing) checked — absent.

③ Code review — key proofs

  • Kernel API proven: summaryMessageId(blockId) is plain prefix concatenation (acp_summary_ + id, kernel dist/index.js:107), so the SUMMARY_ID_PREFIX = summaryMessageId("") slice trick is correct; sub-id projections (acp_summary_b1#r0) slice to non-block strings and safely no-op. CompressionBlock.effectiveMessageIds / directBlockIds / active all exist in the pinned kernel types.
  • Fires on real wire, not just fixtures: opencode speaks the Anthropic wire; acp-kernel/wire maps tool_use → { contentType: "tool-call", toolName: <name>, text: JSON.stringify(input) } and tool_result → the matching tool-result. So JSON.parse(m.text) + top-level field read operates on production-shaped messages.
  • State aliasing safe: applyRanges sets ctx.session.state = res.state (src/stream.ts:356) before syncSubagentSessions, so the prune liveness check sees the same live block set as the fingerprint loop; the capture view (compressMessages ?? messages) is exactly the view the kernel folded against.
  • Conservative semantics correct: empty re-capture retains the prior entry, dead blocks are pruned, T2 refolds inherit via directBlockIds, cap 100/block. Right direction for a retrieval aid (stale extra id ≫ lost id).
  • Immutability: attach never mutates inputs and returns the original array reference when nothing changes (test-pinned).

④ Test validity

Real teeth: byte-identical stored-summary pin, exact receipt substrings, exact map equality, array-reference identity on no-op paths, a genuine no-regex proof (prose mentions and nested-JSON echoes extract nothing), and full applyRanges integration in both opencode and non-opencode. Capture/sync/attach regressions would all be caught.
Minor gaps (non-blocking): no test for an id carried only in a tool-result message (the module scans those too, but that's incidental — I'd rather not pin it); no sub-id-projection safety test; the green e2e lanes don't spawn a real sub-agent, so true end-to-end field-name coverage is untested (see residual below).

⑤ Diff cleanliness

Single-purpose commit, 4 files (+321/−2), branch is current master (merge-base == master HEAD), no lockfile churn, no version bump, no whitespace noise — every line serves the stated purpose. Duplicate screening: no parallel or overlapping items beyond the #1702/#1704 lineage.

Gates (re-run locally @410ca72e + CI)

  • Local: npm ci ✓ · npm run typecheck ✓ · targeted suite (subagent-sessions + compress-fingerprint-refold + loop-compress + preflight-compress + preflight-covered-ranges + absorb + fix-1124-receipt) 55/55 ✓ · npm run build ✓ (dist-import-annotations: OK)
  • CI @410ca72e: all 13 check runs green — unit matrix ubuntu/windows × 22/24, real codex/dsh/pi/opencode lane e2e, hermetic registry e2e, build-artifact, version-guard, CODEX_HOME overlay check

Behavior-change disclosure (old → new)

  1. Compress receipt: previously ended at the [feature] compress 回执加摘要指纹 + inline decompress 回执给再折叠指引 #1294 fingerprint lines; now may gain one [acp-subagent-sessions bN: ses_…] line per changed block carrying captured ids. Scope: opencode sessions, only when ids are captured.
  2. Outbound rendered active-block summary messages: may gain a trailing \n\n[acp-subagent-sessions: ses_…] line. Same scope.

Stored block summaries, non-opencode lanes, and every other client's wire bytes are unchanged.

Residual risk (one — worth a post-merge check)

Field-name coverage: capture reads top-level sessionId / sessionID / session_id. Everything up to that step is proven end-to-end, but no in-repo artifact pins opencode's actual argument key (issue #1702 was written from the #1563 incident data). If the real key differs, capture degrades gracefully — feature inert, zero regression — so this is a liveness risk, not a safety risk. Suggest confirming against one real opencode session log after merge and extending the key list if needed.


中文摘要:审查了 PR #1722(opencode 子代理 ses_ id 跨折叠机械保真:结构化字段捕获 → metadata sidecar → receipt/渲染时 attach,仅 opencode)——核对了 kernel/wire 管线与全部触碰路径的既有行为(#1294 指纹行、receipt、四条 wire 管线、持久化、非 opencode 通道均保持或已披露的有意变更),本地 typecheck/build/定向测试 55/55 通过、CI 13/13 全绿,测试有牙、diff 干净、无意外回归,可以合并(按 §7.4 属 wire 形态变更,由人工合并)。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Follow-up commit 85f8238 — real-client e2e proof (opencode v2 lane) + a capture fix it uncovered.

Capture fix

opencode 2.x renames the dispatch tool task → subagent and its result envelope to <subagent sessionID="ses_…" state="completed"> (packages/core/src/tool/plugin/subagent.ts:16, v1 migration packages/core/src/v1/config/migrate.ts:119). The original commit only matched the 1.x <task id="…"> shape, so on 2.x the capture would have silently missed every dispatch. TASK_ENVELOPE_RE now accepts both machine envelope shapes on both dispatch tool names (still head-anchored, per the #1704 review direction: fields, not prose patterns).

E2E (tests/e2e/e2e-subagent-sessions.test.ts, gated ACP_TEST_E2E_OC_NATIVE=1 + E2E_OC_BIN)

Drives the real opencode 2.0.4 source checkout through the built native plugin + the deterministic fake upstream (zero network, zero tokens):

  1. filler load run → establishes parent conv + fillerRef
  2. subagent dispatch run → asserts the child reaches the upstream as its own plugin-stamped conversation, and the <subagent sessionID=…> envelope lands in the resent parent history
  3. three push-back runs (moves the dispatch pair out of the protected zone)
  4. explicit-range compress run → asserts the compress receipt carries [acp-subagent-sessions bN: ses_…], the persisted sidecar maps block → child session id, and the block summary stays byte-clean (child id never leaks into it)

Result: 1/1 pass in 39s with E2E_OC_BIN=~/tmp/bc-e2e-oc2/oc2 (bun-run 2.0.4 checkout).

Harness findings encoded in the test (opencode 2.x quirks)

  • oc run 2.x shell-quotes argv prompts containing spaces (packages/cli/src/run/run.ts formatMessage), so scripted JSON directives must ride stdin (piped text bypasses the quoting).
  • Plugin dirs need server.js + tui.js/rpc.js stubs beside index.js: Host.resolve rethrows uncoded ResolveMessage for missing entrypoints, which aborts the whole plugin reload — including the built-in opencode.agent plugin → Agent not found: "build".
  • Managed-service port pins go to service-local.json (channel=local); teardown kills by the registration pid (a leaked service persists its broken plugin state forever).

Gates

  • typecheck ✅
  • tests/subagent-sessions.test.ts 7/7 ✅ (now covers the 2.x envelope, cross-shape, and negatives)
  • sibling e2e on the v1 lane 4/4 ✅ (shared fake got an additive lastUserHead oracle field — no regression)
  • full suite: only the 5 known master-baseline local-env failures (omp-native/opencode-native/opencode-v2/pi-native/plugin-agent) — no regressions
  • build ✅

ranxianglei and others added 3 commits September 30, 2026 12:25
… a real fold (#1702)

opencode 2.x renames the dispatch tool task->subagent and its result
envelope to <subagent sessionID=...> (packages/core/src/tool/plugin/
subagent.ts:16): capture now accepts both machine envelope shapes on
both dispatch tool names, head-anchored as before.

New e2e (tests/e2e/e2e-subagent-sessions.test.ts) drives the REAL
opencode 2.0.4 source checkout through the built native plugin: filler
load -> subagent dispatch (child conversation + envelope verified at the
fake upstream) -> push-backs -> explicit-range compress; asserts the
receipt carries [acp-subagent-sessions bN: ses_...], the persisted
sidecar maps block->child id, and the block summary stays byte-clean.

Harness notes: oc run 2.x shell-quotes argv prompts containing spaces
(run.ts formatMessage) so scripted JSON directives must ride stdin;
plugin dirs need server/tui/rpc stubs beside index.js (Host.resolve
rethrows uncoded ResolveMessage -> whole reload aborts -> Agent not
found); managed-service pins land in service-local.json (channel=local)
and teardown kills by registration pid.
Master #1699 routes opencode v2 title-gen requests verbatim (no ref tags);
they re-send the filler text and race the main turn in the reqLog oracle, so
the filler row lookup must filter on the title marker — mirrors the sibling
suite e2e-native-opencode.test.ts.
@ranxianglei
ranxianglei force-pushed the 2026-09-30_subagent-session-sidecar branch from 85f8238 to b6fec1a Compare September 30, 2026 04:32
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

Reviewed follow-up commit 85f8238, rebased the branch onto current master (it had gone dirty — no CI had started), and verified everything end-to-end. Verdict: mergeable (human merge per §7.4 — wire/message-shape change).

Review of 85f8238

Capture fix — correct and necessary. The 2.x rename (task → subagent, <subagent sessionID="…"> envelope) was a real miss in the first commit: on 2.x the original regex matched nothing and the feature silently no-oped. The fix reads the machine-written result-envelope header, head-anchored on the trimmed first line of a tool-result whose toolName ∈ {task, subagent} — still structured-field territory per the #1704 direction, and the negative proofs (buried marker, malformed id, missing attr, foreign tool, bash text all rejected) show the strictness holds. This also closes my round-1 residual risk: the real carrier is the tool-RESULT envelope, not the call args — the field-name question is answered by your own harness discovery, with the JSON-field path remaining as the generic fallback.

New e2e — independently reproduced. Hermetic (isolated XDG/HOME/cwd, listen(0) ports, pid-keyed teardown), real client, zero tokens/network. I ran both lanes locally on the installed binaries:

  • v1 sibling lane (E2E_OC_BIN → opencode 1.14.46): 4/4 pass (111s)
  • v2 subagent lane (E2E_OC_BIN → opencode 2.0.3): 1/1 pass (35s) — receipt carries [acp-subagent-sessions bN: ses_…], persisted sidecar maps block → child id, folded block summary stays byte-clean.

The harness quirks you encoded (stdin-routed directives, server.js/tui.js stubs beside index.js, service-local.json port pins, kill-by-registration-pid) match what I observed when driving the 2.x binary myself — these are load-bearing, not vestigial.

Rebase report (cc35757 → 5fc5989, ~10 master commits)

Master moved while the PR sat open — log-privacy scrub (#1718), decompress-empty-range (#1713), transport retry (#1693/#1688), titlegen intent routing (#1703/#1699), fetch-chain-depth (#1672), quiet-unrouted-get (#1661). Three files overlapped the PR:

  1. src/stream.ts conflict (resolved): 日志清理隐私信息 #1718 split the compress receipt into a model-facing copy (msg, full [feature] compress 回执加摘要指纹 + inline decompress 回执给再折叠指引 #1294 fingerprints) and a log copy (logMsg, length-only summaryFingerprintLogLine). Union applied: the PR's changedBlocks filter + syncSubagentSessions drive both copies; each subagent note line goes into both — it carries a block id + opaque ses_ identifier, never summary text, so it's outside 日志清理隐私信息 #1718's excerpt-scrub scope (block ids already appear in bili.log). Rationale left as a code comment at the append site.
  2. tests/e2e/fake-upstream-chat.mjs: auto-merged clean — lastUserHead (PR) and title: marker (opencode v2 title-gen requests get injected with compression philosophy (misclassified by isTitleGen) #1699) coexist in the oracle row.
  3. New labeled commit b6fec1a0 (test-only, 4 lines): the new e2e's filler-row lookup now filters && !o.title. Under opencode v2 title-gen requests get injected with compression philosophy (misclassified by isTitleGen) #1699's verbatim title routing, the v2 title side-channel re-sends the filler text with no ref tags and races the main turn in reqLog — the exact flake the sibling suite just fixed; ours had the same latent race. Mirrors e2e-native-opencode.test.ts.

Pushed --force-with-lease after confirming the remote tip was still 85f8238 (no third-party advance). Final branch: a0692567 → 081247a1 → b6fec1a0 on 5fc59894; diff vs master is 6 files, +990/−5, every line purpose-related.

Gate evidence (all on rebased head b6fec1a)

  • typecheck ✓ · build ✓ (dist-import-annotations: OK)
  • tests/subagent-sessions.test.ts 7/7 ✓
  • targeted regression (compress/stream/receipt/absorb/preflight + 日志清理隐私信息 #1718 fingerprint tests) 51/51 ✓
  • local e2e: v1 sibling 4/4 ✓, v2 subagent 1/1 ✓
  • CI: 13/13 check runs success (full matrix incl. real-client lanes, hermetic registry e2e, version-guard)

Regression audit (existing behaviors touched)

Behavior change disclosure (old → new)

  • Compress receipt: gains one optional [acp-subagent-sessions bN: ses_…] line per changed block carrying captured ids.
  • Rendered active-block summary messages (proxy mode): gain a trailing id line after absorb-view application.
  • bili.log: now also carries the note lines (round-1 design was receipt-only; moved during rebase per the 日志清理隐私信息 #1718 analysis above — identifiers only, no excerpts).
  • All three: opencode sessions only, only when ids were captured, and since 85f8238 cover both 1.x <task id> and 2.x <subagent sessionID> shapes. Nothing changes for any other client or for folds without dispatch pairs.

Optional follow-up (your call, not filed)

ci-e2e-native-opencode.yml runs only e2e-native-opencode.test.ts — the new subagent e2e is currently local-only proof. Its pinned @opencode/cli@2.0.3 install is exactly what E2E_OC_BIN needs, so adding the suite to that workflow is zero-new-infrastructure; say the word and I'll add it as a tiny follow-up commit (or leave it a manual gate).


中文摘要:复审了 2.x envelope 捕获修复与新真实客户端 e2e(本地独立复现 v1 4/4、v2 1/1);分支已 rebase 到最新 master,解决了与 #1718 日志脱敏在 stream.ts 的冲突(sidecar 注记只含块号与 ses_ 标识、无摘要文本,双份保留),并补了一个针对 #1699 title 侧信道竞态的 4 行 e2e 小修;全部门禁含 CI 13/13 绿,无回归,可以合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

折叠时按消息类型机械保真:opencode 子代理派发对的 seg id 由代码强制写入压缩块(#1563 根因修复)

1 participant