Skip to content

fix(opencode): persist synthetic context outside user messages - #524

Merged
taosu0216 merged 3 commits into
mindfold-ai:mainfrom
kakamisamas:fix/opencode-synthetic-context-parts
Aug 10, 2026
Merged

fix(opencode): persist synthetic context outside user messages#524
taosu0216 merged 3 commits into
mindfold-ai:mainfrom
kakamisamas:fix/opencode-synthetic-context-parts

Conversation

@kakamisamas

@kakamisamas kakamisamas commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • persist SessionStart and workflow-state context as separate synthetic OpenCode text parts
  • preserve ordinary user text, file parts, metadata, identity, and relative order
  • assign deterministic complete part identities so first-use order matches stored replay order
  • keep template and dogfood copies byte-identical and document the persisted-part contract

Verification

  • pnpm install --frozen-lockfile
  • pnpm typecheck
  • pnpm --filter @mindfoldhq/trellis lint:py (0 errors; existing unused-import warnings only)
  • pnpm lint
  • pnpm build
  • pnpm test (Core: 333 passed, 1 skipped; CLI: 1645 passed; OpenCode: 70 passed)
  • git diff --check
  • template/dogfood byte comparison and node --check for all changed JavaScript files
  • GitNexus change analysis: low risk, 0 affected processes

Risk

The identity helper intentionally accepts the current persisted OpenCode prt_ ID format. Unsupported or incomplete identities fail closed: plugins log the error and preserve all ordinary input parts unchanged. Regression coverage includes both plugin orders, attachment-only turns, duplicate refusal, persisted-history dedupe, compaction, and ID-sorted replay.

Rollback

Revert commit ac66e207; this restores the previous in-place context injection behavior without a data migration.

Summary by CodeRabbit

  • New Features

    • Added persisted synthetic context entries for session-start and workflow-state information.
    • Preserved original prompts while keeping injected context separate.
    • Added deterministic ordering and deduplication for injected context.
  • Bug Fixes

    • Prevented context injection from modifying user-authored text.
    • Improved handling of invalid or missing message information.
  • Tests

    • Expanded coverage for persistence, ordering, validation, skip conditions, replay behavior, and prompt preservation.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8694f8a0-0b91-47c2-b7db-855ca8935d75

📥 Commits

Reviewing files that changed from the base of the PR and between 2dd02f5 and 9a660d0.

📒 Files selected for processing (4)
  • .opencode/plugins/session-start.js
  • .trellis/spec/cli/backend/workflow-state-contract.md
  • packages/cli/src/templates/opencode/plugins/session-start.js
  • packages/cli/test/templates/opencode.test.ts
💤 Files with no reviewable changes (2)
  • .opencode/plugins/session-start.js
  • packages/cli/src/templates/opencode/plugins/session-start.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • .trellis/spec/cli/backend/workflow-state-contract.md

📝 Walkthrough

Walkthrough

OpenCode now inserts SessionStart and workflow-state context as deterministic synthetic text parts. Shared helpers validate persisted identities, preserve ordinary user text, and keep part ordering stable. Plugins and tests now use the persisted-part flow.

Changes

OpenCode context persistence

Layer / File(s) Summary
Synthetic-part contract and insertion helpers
.opencode/lib/context-visibility.js, packages/cli/src/templates/opencode/lib/context-visibility.js, .trellis/spec/cli/backend/workflow-state-contract.md
Adds user-text discovery, deterministic synthetic IDs, identity validation, ordered insertion, duplicate detection, and mutation safety.
Session and workflow plugin integration
.opencode/plugins/*, packages/cli/src/templates/opencode/plugins/*
Both plugins insert separate synthetic parts while preserving the original user text and applying skip-keyword checks to ordinary text.
Template propagation and regression coverage
packages/cli/test/templates/opencode.test.ts, packages/cli/test/regression.test.ts
Adds coverage for persisted identities, insertion order, validation, plugin ordering, skip behavior, unchanged prompts, and template collection.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionStart
  participant WorkflowState
  participant contextVisibility
  SessionStart->>contextVisibility: insert sessionStart synthetic part
  WorkflowState->>contextVisibility: find ordinary user text
  WorkflowState->>contextVisibility: insert workflowState synthetic part
  contextVisibility->>SessionStart: return ordered persisted part
  contextVisibility->>WorkflowState: preserve ordinary user part
Loading

Possibly related PRs

Suggested reviewers: taosu0216

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: persisting synthetic OpenCode context outside ordinary user messages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
.opencode/lib/context-visibility.js (1)

42-44: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Ordinal guard checks one slot instead of both, in both identical helper copies. With sourceOrdinal === 2, workflowState succeeds and sessionStart throws, so the turn persists one context slot. The contract requires a throw when the ordinal cannot reserve both slots.

  • .opencode/lib/context-visibility.js#L42-L44: compare sourceOrdinal against the maximum offset of all CONTEXT_PART_KINDS entries.
  • packages/cli/src/templates/opencode/lib/context-visibility.js#L42-L44: apply the identical change to preserve byte parity.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.opencode/lib/context-visibility.js around lines 42 - 44, The ordinal guard
in both copies of the context-visibility helper must validate against the
maximum offset among all CONTEXT_PART_KINDS entries, ensuring an ordinal can
reserve both context slots before proceeding. Update
.opencode/lib/context-visibility.js lines 42-44 and
packages/cli/src/templates/opencode/lib/context-visibility.js lines 42-44
identically to preserve byte parity.
packages/cli/test/templates/opencode.test.ts (1)

616-618: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Compare IDs by code units, not by locale collation.

localeCompare applies locale collation. insertSyntheticTextPart orders IDs with the code-unit operators < and >, and stored replay sorts bytes. Real OpenCode IDs mix uppercase and lowercase base62 characters, where the two orders differ. The current fixtures use lowercase suffixes only, so this test can pass while byte-order replay differs.

Use the same comparator as the implementation. The same change applies at lines 1013-1022.

♻️ Proposed fix
       expect(parts.map(part => part.id)).toEqual(
-        [...parts].sort((left, right) => left.id.localeCompare(right.id)).map(part => part.id),
+        [...parts]
+          .sort((left, right) =>
+            left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
+          )
+          .map(part => part.id),
       );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/test/templates/opencode.test.ts` around lines 616 - 618, Update
the ID sorting assertions in the test, including the comparable assertion around
the second referenced range, to use code-unit ordering with the same direct
comparison semantics as insertSyntheticTextPart and replay sorting instead of
localeCompare. Preserve the existing mapped ID expectations while ensuring
mixed-case base62 IDs are ordered consistently with the implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.opencode/lib/context-visibility.js:
- Around line 42-44: The ordinal guard in both copies of the context-visibility
helper must validate against the maximum offset among all CONTEXT_PART_KINDS
entries, ensuring an ordinal can reserve both context slots before proceeding.
Update .opencode/lib/context-visibility.js lines 42-44 and
packages/cli/src/templates/opencode/lib/context-visibility.js lines 42-44
identically to preserve byte parity.

In `@packages/cli/test/templates/opencode.test.ts`:
- Around line 616-618: Update the ID sorting assertions in the test, including
the comparable assertion around the second referenced range, to use code-unit
ordering with the same direct comparison semantics as insertSyntheticTextPart
and replay sorting instead of localeCompare. Preserve the existing mapped ID
expectations while ensuring mixed-case base62 IDs are ordered consistently with
the implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3cd4c163-9b63-410b-b441-38afc738b333

📥 Commits

Reviewing files that changed from the base of the PR and between 7b17052 and ac66e20.

📒 Files selected for processing (9)
  • .opencode/lib/context-visibility.js
  • .opencode/plugins/inject-workflow-state.js
  • .opencode/plugins/session-start.js
  • .trellis/spec/cli/backend/workflow-state-contract.md
  • packages/cli/src/templates/opencode/lib/context-visibility.js
  • packages/cli/src/templates/opencode/plugins/inject-workflow-state.js
  • packages/cli/src/templates/opencode/plugins/session-start.js
  • packages/cli/test/regression.test.ts
  • packages/cli/test/templates/opencode.test.ts

@taosu0216

Copy link
Copy Markdown
Contributor

Reviewed this in depth. The premise holds and I verified it against OpenCode 1.17.18's actual implementation rather than against the description — extracted the embedded JS from the installed binary and checked a live opencode.db:

  • SessionPrompt.createUserMessage stamps id/messageID/sessionID onto every part before triggering chat.message, then iterates the same mutated array calling updatePart. So in-place splice/push is the correct contract, and the old ID-less parts.unshift(...) was indeed handing OpenCode a part it had to invent an ID for.
  • The read path is orderBy(part.message_id, part.id), so lexicographic part ID really is replay order. The central claim of the design is true.
  • synthetic: true is a real schema field, and the model conversion filters on !part.ignored while the UI filters on !synthetic && !ignored. That is exactly the right split — it also fixes a real annoyance where reverting a message restored the user's prompt with ~8KB of Trellis context glued to the front.
  • The bug is currently shipping: a part in today's dogfood session is a single 8470-byte text part containing <session-context>…\n\n---\n\n<workflow-state>… followed by the user's actual sentence.

It also rebases onto current main with no conflicts, and the full suite is green there (1658 passed / 72 files), with the .opencode/ dogfood copies byte-identical to the templates. Thanks for keeping those in sync — that seam has bitten us before.

Three things I'd like before merge. Two are missing tests that let real mutations survive; I ran the mutations rather than eyeballing coverage.

1. The "identity source must be the minimum part" invariant is undefended.

I inverted the comparator in findIdentitySourcePart (context-visibility.js:26-37) so it picks the largest ordinary part instead of the smallest. Zero tests failed — 489 passed unchanged. No fixture anywhere puts two ordinary parts in one array; the attachment tests use attachment-only messages, so "typed text plus a dragged-in file" is never exercised.

Why it matters, with real ID shapes from the live DB — text prt_b9634c88c001… and file prt_b9634c88c002…, same millisecond, counters 001/002:

  • min-source (shipped): synthetic ordinals …bfff / …c000, both below both real parts, correctly borrowing across the millisecond boundary.
  • max-source: workflow-state gets ordinal …c001, an identical 12-hex prefix to the user's text part. Ordering then falls to the 14-char tail, and a real tail beginning with 0 (1 in 62) puts the breadcrumb after the user's prompt.

Min-selection is what makes the scheme safe. Could you add a case with a text part and a file part asserting both synthetic IDs sort below both ordinary parts?

2. The ID-format strictness is also untested, and drift is not hypothetical.

Loosening PART_ID_PATTERN (context-visibility.js:3) from [0-9A-Za-z]{14}$ to [0-9A-Za-z]*$ likewise killed zero tests. The existing "missing identity" case only covers a part with no id at all.

In the live DB, 2435/2440 IDs match the current pattern and 5 use an older 19-char format (prt_19b9634d8e5924s6zx6) — OpenCode has already changed this once. When it changes again, injection stops entirely on OpenCode. User parts are preserved, so the fail-closed claim is true, but the only signal is an append to the hardcoded /tmp/trellis-plugin-debug.log, which is swallowed on Windows.

Worth noting the pattern is stricter than the algorithm needs: only the leading 12 hex participate in ordering, so a tail-length change alone would be harmless to correctness yet currently kills injection. A test using that real legacy ID, asserting user parts survive untouched, would pin the intended behavior either way.

3. The session.compacted handler is dead code, and the rewritten test now certifies it.

session-start.js:23-37 clears the in-memory flag so context can be re-injected after compaction. It cannot work: client.session.messages returns the whole session (filterCompacted applies only when building model messages), so hasPersistedInjectedContext still finds the pre-compaction metadata.trellis.sessionStart marker and re-suppresses immediately. On overflow-triggered compaction OpenCode also copies the previous user message's non-compaction parts forward into a fresh message, carrying the marker with them.

The new test fires session.compacted and then asserts the second message gets no injection — which documents the handler doing nothing, while the comment above it says "Cleared processed flag after compaction". Either delete the handler or make the dedupe compaction-aware; a test that makes dead code look deliberate is worse than no test.

(For what it's worth, I checked the two failure modes I was worried about and both are fine: double-injection after compaction cannot happen, and permanent suppression from a false-positive dedupe is not realistic, since a session.messages failure returns false and so fails toward re-injecting.)

Optional, cheap: contextCollector.markProcessed(sessionID) sits at session-start.js:84, after the throwing insert. If the ID format is ever unsupported, every subsequent message re-runs a full session.messages fetch plus a full buildSessionContext filesystem walk and throws again, for the rest of the session. Moving the mark above the insert, or adding a per-session "identity unsupported" latch, bounds that. This retry loop is new — the pre-PR path could not fail.

A few things I checked and found clean, so you don't need to re-litigate them: inject-subagent-context.js is genuinely unaffected (it only registers tool.execute.before, never reads output.parts, and neither lib/trellis-context.js nor lib/session-utils.js is in the diff — the export TRELLIS_CONTEXT_ID=… bash prefixing is untouched); ID collision analysis holds up given part.id is a global primary key; and lib/context-visibility.js earns its place, since insertSyntheticTextPart has two call sites in two separately-loaded plugin modules and CONTEXT_PART_KINDS is the shared contract that makes them order correctly regardless of load order.

Test quality is otherwise strong — I ran 12 mutations and 10 were killed, several by many tests at once, and the structuredClone deep-comparison baselines are doing real work. The two survivors above are the gaps.

One thing I could not determine: whether the desktop/web client renders synthetic user text parts inside the message bubble. I confirmed they are filtered out of prompt reconstruction, timeline, fork, copy and revert. Cosmetic either way, and no worse than today's inline concatenation.

…andler

Addresses the three review items on mindfold-ai#524.

The `session.compacted` handler cannot work. It clears the in-memory
processed flag, but `chat.message` then calls `hasPersistedInjectedContext`,
which reads the whole session with no compaction filter and still finds the
pre-compaction marker — so the clear is undone on the very next line. Removed
it, and reworked the test that fired the event so it exercises the live path
(a fresh process meeting an already-marked session) instead of certifying
dead code. Re-injecting context after compaction is now recorded in the spec
as a known gap rather than implied to work.

Two mutations survived the suite before this change:

- Inverting the comparator in `findIdentitySourcePart` killed zero tests; no
  fixture had two ordinary parts in one message. Added a text-part plus
  file-part case using real ID shapes from a live DB.
- Loosening `PART_ID_PATTERN` also killed zero tests; the only coverage was a
  part with no `id` at all, which fails an earlier guard. Added a case using
  the 19-character legacy format OpenCode still has in the wild, asserting the
  user's parts are left untouched.

The obvious assertion for the first one — both synthetic IDs sort below both
ordinary IDs — passes with the comparator inverted, because the deciding byte
becomes a digit against a letter. It now pins the exact ordinal prefixes that
only minimum-sourced selection produces.
@taosu0216
taosu0216 merged commit c8e327a into mindfold-ai:main Aug 10, 2026
2 checks passed
@kakamisamas
kakamisamas deleted the fix/opencode-synthetic-context-parts branch August 10, 2026 13:38
taosu0216 pushed a commit that referenced this pull request Aug 14, 2026
Brings 18 commits from main, including 0.6.15, the DSH minimal adapter
(#547), and the marker-label routing fix. Beta stays on 0.7.0-beta.3 and
keeps its own submodule pointers.

Git merged three files cleanly whose *content* was contradictory, so
those are the ones worth re-reviewing:

- workflow.md: main puts dsh in the inline-execution group (#547 has no
  sub-agent surface), beta puts it in the class-2 sub-agent group (#548
  added one). Both edits applied, leaving dsh in both groups. Resolved to
  beta's semantics — dsh removed from the inline markers in the template,
  the dogfood copy and the marketplace mirror.
- dsh was registered twice: once by #547 on main and once by #548 on
  beta, in cli/index.ts, commands/init.ts and configurators/index.ts.
  Different lines each time, so no conflict was raised; tsc caught it.
  Deduped, keeping beta's wording.
- opencode.test.ts: beta's layered-workflow tests fed the plugin a bare
  {type,text} part, which #524 on main no longer accepts —
  insertSyntheticTextPart requires an ordinary part with a persisted
  identity and now emits the breadcrumb as its own part instead of
  rewriting the user's text. The tests asserted on parts[0] and silently
  saw the unmodified prompt. Rewritten against the synthetic-part
  contract; they still verify the layered resolution they were written
  for.

Declared conflicts resolved to beta for everything dsh-related and for
the version files; workflow_phase.py took main's _PLATFORM_MARKER_LABELS
table, which subsumes beta's dsh-only alias.

Verified on the merged tree rather than assumed:
- core 344 passed, cli 1825 passed, lint and tsc clean
- all 22 platforms resolve a non-empty routing block
- dsh resolves the sub-agent route, not the inline one
- built the CLI and ran `init --dsh`: 86 files, with the three
  trellis-agent-* role skills beta expects
- marketplace mirror byte-identical to the bundled template
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.

2 participants