From fbf01b80c67b40119ded3778755dd0abf1e4b8f2 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sun, 28 Jun 2026 19:14:39 +0800 Subject: [PATCH 1/2] chore: bump version to 1.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 492d380..5eb73f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-acp", - "version": "1.4.2", + "version": "1.5.0", "type": "module", "description": "Active Context Pruning — model-driven context management for OpenCode (hardened fork of DCP with 34 bug fixes)", "main": "./dist/index.js", From 41d35a43a4225f787475e7bbc57796857f1ebaf4 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Mon, 29 Jun 2026 01:32:57 +0800 Subject: [PATCH 2/2] fix: mark injected suffix user messages synthetic to restore title generation ACP's message-transform pipeline injects a 'Compressed block context' nudge as a second, standalone user message (createSuffixMessage -> createSyntheticUserMessage). That message's text part lacked the synthetic flag, so OpenCode's SessionPrompt.ensureTitle counted it as a real user message -> its 'filter(real).length !== 1' precondition failed -> title generation was never scheduled (sessions stuck on 'New session - '). createSyntheticUserMessage now marks its text part synthetic: true (matching its name and OpenCode's own synthetic-part convention). ensureTitle excludes all-synthetic user messages, while toModelMessagesEffect still includes synthetic text parts in the LLM call, so the nudge text is still delivered. Refs: GitHub #15 (ranxianglei/opencode-acp) --- .../REQ.md | 46 +++++++++++++++ .../WORKLOG.md | 58 +++++++++++++++++++ lib/messages/utils.ts | 1 + tests/inject.test.ts | 31 ++++++++++ 4 files changed, 136 insertions(+) create mode 100644 devlog/2026-06-29_synthetic-suffix-fixes-title-gen/REQ.md create mode 100644 devlog/2026-06-29_synthetic-suffix-fixes-title-gen/WORKLOG.md diff --git a/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/REQ.md b/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/REQ.md new file mode 100644 index 0000000..8e41e40 --- /dev/null +++ b/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/REQ.md @@ -0,0 +1,46 @@ +# REQ — synthetic suffix messages must not break OpenCode title generation + +## Problem + +When `opencode-acp` is loaded, OpenCode's built-in session title generation +never runs: new sessions stay named `New session - ` indefinitely +(GitHub issue #15, confirmed reproducing on acp 1.5.0 by @kratky-pavel). + +## Root cause + +OpenCode's `SessionPrompt.ensureTitle` (`packages/opencode/src/session/prompt.ts`) +has a hard precondition: + +```ts +const real = (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) +if (input.history.filter(real).length !== 1) return // requires EXACTLY 1 real user message +``` + +ACP's message-transform pipeline injects a "Compressed block context" nudge as a +**second, standalone user message** (`createSuffixMessage` → +`createSyntheticUserMessage`, `lib/messages/inject/inject.ts` + `lib/messages/utils.ts`). +`createSyntheticUserMessage` built the message's text part **without** the +`synthetic: true` flag, so `ensureTitle`'s `real` filter counted it as a real +user message → `filter(real).length` became 2 → the precondition failed → +title generation was never scheduled (the title LLM stream never starts). + +This is a different code path from PR #16's `isInternalAgentRequest` guard +(which only handles the case where an internal-agent request itself flows +through `messages.transform`). The bug here is the **main-chat** transform +injection breaking title **scheduling**. + +## Requirement + +ACP-injected user messages must NOT be counted as "real" user messages by +OpenCode's `ensureTitle`, while still delivering their text to the LLM and being +hidden from the TUI — i.e. they must behave exactly like OpenCode's own +synthetic parts (compaction summaries, plan parts). + +## Approach + +Mark the part created by `createSyntheticUserMessage` with `synthetic: true` +(the function is literally named "synthetic" but never set the flag — a latent +bug). Verified that OpenCode's `MessageV2.toModelMessagesEffect` includes +synthetic text parts in the LLM call (it checks only `type === "text" && +!part.ignored`, with no `synthetic` filter), so the nudge text is still +delivered. diff --git a/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/WORKLOG.md b/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/WORKLOG.md new file mode 100644 index 0000000..cdd9ce9 --- /dev/null +++ b/devlog/2026-06-29_synthetic-suffix-fixes-title-gen/WORKLOG.md @@ -0,0 +1,58 @@ +# WORKLOG — synthetic suffix fixes title generation + +## Investigation + +- Pulled GitHub issue #15 comments via `gh api` (webfetch had missed the + dynamically-loaded comments). Reporter @kratky-pavel confirmed the bug still + reproduces on acp **1.5.0** and shared the ACP daily log + context snapshot. +- The snapshot showed ACP turning a single `"ahoj"` user message into **two** + user messages (the original plus an injected `"Compressed block context: …"` + nudge). No `Skipping message transform for internal agent request` log line → + PR #16's guard never fired (it targets a different code path). +- Read OpenCode `SessionPrompt.ensureTitle` (`prompt.ts:169-229`): precondition + `input.history.filter(real).length !== 1` ⇒ requires exactly one real + (`role==="user"` && not all-parts-synthetic) user message. +- Read the ensureTitle call site (`prompt.ts:1452-1458`): `title({ history: msgs })` + is forked async (`Effect.forkIn`); `msgs` is the same array later mutated by + `experimental.chat.messages.transform` (`prompt.ts:1574`) where ACP pushes the + suffix user message — so the forked title effect can observe the mutated array. +- Located the injection: `createSuffixMessage` (`lib/messages/inject/inject.ts:49`) + → `createSyntheticUserMessage` (`lib/messages/utils.ts:26`). Confirmed the + created text part had **no** `synthetic` field. +- Confirmed `MessageV2.toModelMessagesEffect` (`message-v2.ts:791-806`) includes + synthetic text parts (loop check is `type === "text" && !ignored`, no + `synthetic` filter) ⇒ marking the part `synthetic` keeps the nudge in the LLM + call. TUI already hides synthetic parts. + +## Change + +`lib/messages/utils.ts` — `createSyntheticUserMessage`: added `synthetic: true` +to the text part. Single-line, targeted fix. Benefits all callers (the compress +nudge suffix message and prune.ts compression summaries). + +## Test + +`tests/inject.test.ts` — added a contract test that replicates OpenCode's +`ensureTitle` `real` filter and asserts: + +1. `createSyntheticUserMessage` produces a message whose parts are all + `synthetic: true`. +2. That message is **not** a "real" user message. +3. A plain user message **is** real. +4. A `[base, synthetic]` conversation has **exactly one** real user message — + i.e. `ensureTitle`'s precondition holds. + +## Verification + +- `npm run typecheck` — PASS (`synthetic` is valid on the Part type). +- `npm test` — PASS **487/487** (486 prior + 1 new), 0 fail. +- `npm run build` — PASS. + +## Notes / follow-ups + +- The fork's `37ecd0d` ("gate per-message nudge by context growth") does **not** + touch `createSuffixMessage`, so the bug was present in both the gitea fork and + upstream `@latest`; this fix applies to both. +- `isInternalAgentRequest` in `createChatMessageTransformHandler` is effectively + dead code (OpenCode never sets `info.agent` to `title/summary/compaction` on + user messages that reach `messages.transform`); left as harmless defense. diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 319f323..6fe0cde 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -50,6 +50,7 @@ export const createSyntheticUserMessage = ( messageID: messageId, type: "text" as const, text: content, + synthetic: true, }, ], } diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 3464aa2..b001b2d 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -3,6 +3,7 @@ import test from "node:test" import type { PluginConfig } from "../lib/config" import { Logger } from "../lib/logger" import { injectMessageIds, injectCompressNudges } from "../lib/messages/inject/inject" +import { createSyntheticUserMessage } from "../lib/messages/utils" import { createSessionState, type WithParts } from "../lib/state" import { formatMessageIdTag } from "../lib/message-ids" @@ -156,3 +157,33 @@ test("formatMessageIdTag produces dcp-message-id tag", () => { assert.ok(tag.includes("m00001")) assert.ok(tag.includes("dcp-message-id")) }) + +// OpenCode's SessionPrompt.ensureTitle treats a user message as "real" only when +// NOT all of its parts are synthetic (opencode prompt.ts: +// m.info.role === "user" && !m.parts.every(p => "synthetic" in p && p.synthetic) +// ) and bails out unless the conversation contains EXACTLY one real user message. +// ACP's compress-nudge suffix message is created via createSyntheticUserMessage and +// pushed as a second user message; if it counted as real, title generation would +// never be scheduled. This test locks the contract: the suffix message must be +// all-synthetic so ensureTitle still sees exactly one real user message. +const isOpenCodeRealUserMessage = (m: WithParts): boolean => + m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && (p as { synthetic?: unknown }).synthetic === true) + +test("createSyntheticUserMessage produces an all-synthetic user message that ensureTitle does not count as real", () => { + const base = userMsg("u1", "hello") + const synthetic = createSyntheticUserMessage(base, "") + + assert.ok( + synthetic.parts.every((p) => "synthetic" in p && (p as { synthetic?: unknown }).synthetic === true), + "every part of a createSyntheticUserMessage result must carry synthetic:true", + ) + assert.equal(isOpenCodeRealUserMessage(synthetic), false, "synthetic user message must NOT be a 'real' user message") + assert.equal(isOpenCodeRealUserMessage(base), true, "a plain user message must still be 'real'") + + const conversation = [base, synthetic] + assert.equal( + conversation.filter(isOpenCodeRealUserMessage).length, + 1, + "after ACP injects its suffix message the conversation must still have exactly one real user message (ensureTitle precondition)", + ) +})