Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions devlog/2026-06-29_synthetic-suffix-fixes-title-gen/REQ.md
Original file line number Diff line number Diff line change
@@ -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 - <timestamp>` 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.
58 changes: 58 additions & 0 deletions devlog/2026-06-29_synthetic-suffix-fixes-title-gen/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const createSyntheticUserMessage = (
messageID: messageId,
type: "text" as const,
text: content,
synthetic: true,
},
],
}
Expand Down
31 changes: 31 additions & 0 deletions tests/inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)",
)
})
Loading