From 3581d4d68beda0a5e4ad2a5e17c69d4f52933e81 Mon Sep 17 00:00:00 2001 From: Csaba Fabian Date: Mon, 12 Oct 2026 12:55:44 +0000 Subject: [PATCH 1/3] fix(acp): a discussion message that starts with a slash is text, not a command A session/prompt carrying _meta.documentor skips slash-command detection, so a message such as "/cc Anna" is stored as text instead of being run as a command or dropped as an unknown one. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/service.ts | 10 ++- .../opencode/test/acp/service-session.test.ts | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 75e579de9762..ae2eb997ce0a 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -520,9 +520,10 @@ export function make(input: { const variant = current.variant ?? selectVariant(snapshot, selected) const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined) const parts = promptContentToParts(params.prompt) - const command = detectSlashCommand(parts) - const messageID = parts.length > 0 ? opencodeMessageID(params.messageId) : undefined const documentor = documentorMeta(params._meta) + // A DocuMentor message is what a person wrote in a discussion, so a leading slash is text, never a command. + const command = hasDocumentorMeta(params._meta) ? undefined : detectSlashCommand(parts) + const messageID = parts.length > 0 ? opencodeMessageID(params.messageId) : undefined const silent = documentor.noReply === true || documentor.answerable === false if (!command) { @@ -911,6 +912,11 @@ export function withPartMetadata( return parts.map((part, i) => (i === index ? { ...part, metadata: partMetadata } : part)) } +/** Whether a request's `_meta` carries a DocuMentor object, whatever its fields. */ +export function hasDocumentorMeta(meta: unknown): boolean { + return isRecord(meta) && isRecord(meta["documentor"]) +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index c15e0a608ac1..f012a6cda5f2 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -1553,6 +1553,68 @@ describe("ACP service sessions", () => { expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n1", _meta: {} }) }) + it("documentor: a discussion message that starts with a slash is forwarded as text", async () => { + const { service, prompts, commands } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "/cc Anna" }], + _meta: { documentor: { author: { id: "u_alice", name: "Alice" } } }, + }), + ) + + expect(commands).toEqual([]) + expect(prompts).toHaveLength(1) + expect((prompts[0] as { parts: { text: string }[] }).parts[0].text).toBe("/cc Anna") + expect(prompts[0]).toMatchObject({ author: { id: "u_alice", name: "Alice" } }) + expect(result.stopReason).toBe("end_turn") + }) + + it("documentor: a silent discussion message that starts with a slash is stored as text", async () => { + // The fake prompt never pushes an idle event: a regression that waits for idle times out here. + const { service, prompts, commands, events } = makeService([], { + prompt: () => Promise.resolve({ data: { info: userInfo("msg_user_ses_a_1700000000000_n3") } }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const pushed = events.push + events.push = () => {} + + try { + const result = await Effect.runPromise( + service + .prompt({ + sessionId: session.sessionId, + messageId: "msg_user_ses_a_1700000000000_n3", + prompt: [{ type: "text", text: "/init please" }], + _meta: { documentor: { author: { id: "u_bob", name: "Bob" }, noReply: true } }, + }) + .pipe(Effect.timeout("2 seconds")), + ) + + expect(commands).toEqual([]) + expect(prompts[0]).toMatchObject({ noReply: true, author: { id: "u_bob", name: "Bob" } }) + expect((prompts[0] as { parts: { text: string }[] }).parts[0].text).toBe("/init please") + expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n3", _meta: {} }) + } finally { + events.push = pushed + } + }) + + it("documentor: a slash prompt without documentor meta still runs the command", async () => { + const { service, prompts, commands } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init" }], _meta: { other: {} } }), + ) + + expect(prompts).toEqual([]) + expect(commands).toHaveLength(1) + expect(commands[0]).toMatchObject({ command: "init", arguments: "" }) + }) + it("documentor: malformed documentor meta is ignored", async () => { const { service, prompts } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) From 740aa12f2beb02a14121a9dc7bda0490402525c2 Mon Sep 17 00:00:00 2001 From: Csaba Fabian Date: Mon, 12 Oct 2026 12:57:09 +0000 Subject: [PATCH 2/3] fix(acp): an answer that is a user message is end_turn without usage v1 prompt answers with the newest message when the session has nothing answerable, which can be a user message. promptResponse answers end_turn without usage for it instead of reading tokens off a user info. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/service.ts | 24 +++++++++++++++---- .../opencode/test/acp/service-session.test.ts | 11 +++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index ae2eb997ce0a..32214c1f51a6 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -739,7 +739,14 @@ type MessageInfo = { } type AssistantError = NonNullable -type AssistantInfo = (UsageService.AssistantTokenCost & Pick) | undefined +type AssistantInfo = UsageService.AssistantTokenCost & Pick +// The message a prompt answers with: the newest assistant message, or the newest message of any role when the +// session has nothing answerable (SessionPrompt's loop returns it instead of throwing). +type PromptInfo = { readonly role: Message["role"] } | undefined + +function isAssistantInfo(info: PromptInfo): info is AssistantInfo { + return info?.role === "assistant" +} function request(fn: () => Promise>, service?: string) { return Effect.tryPromise({ @@ -922,13 +929,22 @@ function isRecord(value: unknown): value is Record { } const promptResponse = Effect.fn("ACP.promptResponse")(function* ( - info: AssistantInfo, + answer: PromptInfo, messageId: string | null | undefined, ) { - if (!info?.error) { + if (!isAssistantInfo(answer)) { + return { + stopReason: "end_turn" as const, + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } + } + const info = answer + + if (!info.error) { return { stopReason: "end_turn" as const, - ...(info ? { usage: UsageService.buildUsage(info) } : {}), + usage: UsageService.buildUsage(info), ...(messageId ? { userMessageId: messageId } : {}), _meta: {}, } diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index f012a6cda5f2..bd8a6e8033d7 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -1615,6 +1615,17 @@ describe("ACP service sessions", () => { expect(commands[0]).toMatchObject({ command: "init", arguments: "" }) }) + it("documentor: an empty prompt whose v1 answer is a user message answers end_turn", async () => { + const { service } = makeService([], { + prompt: () => Promise.resolve({ data: { info: userInfo("msg_u") } }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise(service.prompt({ sessionId: session.sessionId, prompt: [] })) + + expect(result).toEqual({ stopReason: "end_turn", _meta: {} }) + }) + it("documentor: malformed documentor meta is ignored", async () => { const { service, prompts } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) From a2445676adb46ea3162d54c09152b3075e703099 Mon Sep 17 00:00:00 2001 From: Csaba Fabian Date: Mon, 12 Oct 2026 13:00:37 +0000 Subject: [PATCH 3/3] docs: a prompt of any kind commits a pending revert; FORK.md wording - prompt.ts: the comment above revert.cleanup says a stored-only message commits a pending revert too; a documentor: test pins it. - FORK.md: the loop's nothing-answerable return is the newest assistant message; service.ts skips slash detection for _meta.documentor and answers a user-message answer with end_turn; the SDK is generated from the server's OpenAPI. - tests: the two no-idle-wait tests restore events.push in finally. Co-Authored-By: Claude Fable 5.1 --- documentor/FORK.md | 6 +- packages/opencode/src/session/prompt.ts | 2 + .../opencode/test/acp/service-session.test.ts | 60 ++++++++++--------- packages/opencode/test/session/prompt.test.ts | 28 +++++++++ 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/documentor/FORK.md b/documentor/FORK.md index 483b818ee3d8..115ae1428530 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -25,14 +25,14 @@ what a rebase meets. | `packages/opencode/src/cli/cmd/db.ts` | `db `, `db migrate [--all]`, `db provision`, `db grant-read`, all outside the application runtime | The runtime's database service refuses the schemas these commands exist to create and migrate | | `packages/opencode/src/session/processor.ts` | One lock per processor around each read-then-write of a tool part; `tool-input-delta` and `tool-input-end` do not read a part the processor already tracks | The processor and the running tool both update a tool part. SQLite made each update atomic; a network database lets them interleave, and the tool's metadata was lost. Each read is a round trip, and a large edit streams thousands of deltas | | `packages/opencode/src/acp/service.ts` | `prompt` passes a `messageId` that starts with `msg` to v1 as `messageID`, for a normal prompt and a slash command; an empty prompt passes none. `newSession` passes a `_meta.opencode.sessionId` that starts with `ses` to `session.create` as `id` | DocuMentor names the user message a prompt creates and the session a new chat creates, so their optimistic, live and durable copies share one id. ACP clients that send UUIDs, or no `_meta`, keep opencode-minted ids | -| `packages/opencode/src/acp/service.ts` | `prompt` reads `_meta.documentor = { author, answerable, noReply, system, partMetadata }` and forwards each well-formed field to v1, `partMetadata` as the first text part's `metadata`; a prompt with `noReply` or `answerable: false` is sent without the idle wait and answers `end_turn` at once; `newSession` reads `_meta.documentor.metadata` into `session.create` | ACP is DocuMentor's only channel to the session; a stored-only message produces no idle event; a message's mentions live on its text part | +| `packages/opencode/src/acp/service.ts` | `prompt` reads `_meta.documentor = { author, answerable, noReply, system, partMetadata }` and forwards each well-formed field to v1, `partMetadata` as the first text part's `metadata`; a prompt with `noReply` or `answerable: false` is sent without the idle wait and answers `end_turn` at once; a request carrying `_meta.documentor` is never read as a slash command; an answer that is a user message is `end_turn` without usage; `newSession` reads `_meta.documentor.metadata` into `session.create` | ACP is DocuMentor's only channel to the session; a stored-only message produces no idle event; a message's mentions live on its text part | | `packages/opencode/src/session/prompt.ts` | `prompt` with no parts and no `noReply`, on a session that already has a user message, stores nothing and runs `loop`; on a session with no user message it stores a zero-part row as upstream does. `createUserMessage` stores the message and its parts with `Session.saveMessage` | DocuMentor's abort-then-answer: after a cancel, an empty ACP prompt answers the user messages that arrived during the cancelled step, which opencode already holds. A v1 client sending `parts: []` gets the same resume; upstream clients send none. A reader never sees a user message without all of its parts | -| `packages/opencode/src/session/prompt.ts` | `PromptInput` accepts `author` and `answerable`; `prompt` returns the stored message without a loop when `answerable` is `false`, as for `noReply`; `runLoop` returns the newest message instead of throwing when no answerable user message exists; the loop's model input is built with `attributeAuthors` when the session's metadata is `{ documentor: { kind: "discussion" } }` | A discussion stores human messages as user messages; only an `@mentor` message starts a turn, and that turn must see who said what | +| `packages/opencode/src/session/prompt.ts` | `PromptInput` accepts `author` and `answerable`; `prompt` returns the stored message without a loop when `answerable` is `false`, as for `noReply`; `runLoop` returns the newest assistant message (the newest message only when the session has no assistant message) instead of throwing when no answerable user message exists; a prompt of any kind, a stored-only message included, commits a pending revert; the loop's model input is built with `attributeAuthors` when the session's metadata is `{ documentor: { kind: "discussion" } }` | A discussion stores human messages as user messages; only an `@mentor` message starts a turn, and that turn must see who said what | | `packages/schema/src/v1/session.ts`, `packages/core/src/v1/session.ts` | A user message's info carries `author?: {id, name}` and `answerable?: boolean`; `UserAuthor` is exported | DocuMentor records who typed each message in opencode itself, and marks a discussion message as context that no turn answers | | `packages/opencode/src/session/message-v2.ts` | `latest` skips user messages whose `answerable` is `false`; `toModelMessagesEffect` takes `attributeAuthors` and prefixes the first non-synthetic text part of an authored user message with `Name: `; `authorPrefix` and `isDiscussion` are exported | The message a turn answers is the newest answerable one; attribution is switched by the session kind so ordinary chats send today's prompts | | `packages/opencode/src/session/compaction.ts` | `serialize` prefixes an authored user message with `Name: ` when the session is a discussion (its `process` passes `MessageV2.isDiscussion(session.metadata)`); `prune` and `turns()` count only user messages whose `answerable` is not `false` | A compacted discussion keeps who said what; side remarks do not shrink the protected or retained window | | `packages/opencode/src/session/session.ts` | `CreateInput` and `create` take an optional `id`; `create` refuses an id that names an existing session. `saveMessage` publishes a message and its parts in one transaction | DocuMentor shows a new chat under its final id before opencode answers `session/new`. Each part was its own commit: on a network database a reader between them saw the context part of a user message without its text | -| `packages/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, and `UserMessage` declares both | Generated by `packages/sdk/js/script/build.ts` from the routes above | +| `packages/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, and `UserMessage` declares both | Generated by `packages/sdk/js/script/build.ts` from the server's OpenAPI | | `packages/opencode/src/acp/event.ts` | `agent_message_chunk`, `agent_thought_chunk`, `tool_call` and `tool_call_update`, live and replayed, carry `_meta.opencode = { messageId, parentId }`: the assistant message and the user message it answers. The subscription caches each assistant message's `parentID` from `message.updated` for the sessions it tracks. On a miss it fetches the message once, shared by concurrent updates; a failed fetch is remembered until the session goes idle, and meanwhile the updates carry `parentId: null`. A session's cache is forgotten when it goes idle, and the whole cache when the event stream disconnects | DocuMentor detects from the ACP stream alone, in order, the moment the agent starts answering a message sent while the turn ran | | `packages/opencode/src/acp/event.ts` | A replayed `user_message_chunk` carries `_meta.opencode = { messageId, parentId: null, author?, answerable?, partMetadata? }` | DocuMentor rebuilds a discussion from the replay with authors and mention metadata | | Fork cases in `packages/opencode/test/acp/event.test.ts`, `test/acp/service-session.test.ts`, `test/session/prompt.test.ts`, `test/session/message-v2.test.ts`, `test/session/compaction.test.ts` and `test/session/session.test.ts` | Tests named `documentor: …` | They pin the ACP and session rows above; a rebase keeps them | diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 11a756211615..ba93b300a275 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1054,6 +1054,8 @@ const layer = Layer.effect( "SessionPrompt.prompt", )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + // A prompt of any kind, a stored-only (noReply or unanswerable) message included, commits a pending revert: + // the reverted messages are deleted before anything new is stored. yield* revert.cleanup(session) if ( input.parts.length === 0 && diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index bd8a6e8033d7..55a2f45ad28c 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -1537,20 +1537,23 @@ describe("ACP service sessions", () => { const pushed = events.push events.push = () => {} - const result = await Effect.runPromise( - service - .prompt({ - sessionId: session.sessionId, - messageId: "msg_user_ses_a_1700000000000_n1", - prompt: [{ type: "text", text: "just chatting" }], - _meta: { documentor: { author: { id: "u_bob", name: "Bob" }, noReply: true, answerable: false } }, - }) - .pipe(Effect.timeout("2 seconds")), - ) - events.push = pushed + try { + const result = await Effect.runPromise( + service + .prompt({ + sessionId: session.sessionId, + messageId: "msg_user_ses_a_1700000000000_n1", + prompt: [{ type: "text", text: "just chatting" }], + _meta: { documentor: { author: { id: "u_bob", name: "Bob" }, noReply: true, answerable: false } }, + }) + .pipe(Effect.timeout("2 seconds")), + ) - expect(prompts[0]).toMatchObject({ noReply: true, answerable: false, author: { id: "u_bob", name: "Bob" } }) - expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n1", _meta: {} }) + expect(prompts[0]).toMatchObject({ noReply: true, answerable: false, author: { id: "u_bob", name: "Bob" } }) + expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n1", _meta: {} }) + } finally { + events.push = pushed + } }) it("documentor: a discussion message that starts with a slash is forwarded as text", async () => { @@ -1654,21 +1657,24 @@ describe("ACP service sessions", () => { const pushed = events.push events.push = () => {} - const result = await Effect.runPromise( - service - .prompt({ - sessionId: session.sessionId, - messageId: "msg_user_ses_a_1700000000000_n2", - prompt: [{ type: "text", text: "a side remark" }], - _meta: { documentor: { author: { id: "u_bob", name: "Bob" }, answerable: false } }, - }) - .pipe(Effect.timeout("2 seconds")), - ) - events.push = pushed + try { + const result = await Effect.runPromise( + service + .prompt({ + sessionId: session.sessionId, + messageId: "msg_user_ses_a_1700000000000_n2", + prompt: [{ type: "text", text: "a side remark" }], + _meta: { documentor: { author: { id: "u_bob", name: "Bob" }, answerable: false } }, + }) + .pipe(Effect.timeout("2 seconds")), + ) - expect(prompts[0]).toMatchObject({ answerable: false, author: { id: "u_bob", name: "Bob" } }) - expect(prompts[0]).not.toHaveProperty("noReply") - expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n2", _meta: {} }) + expect(prompts[0]).toMatchObject({ answerable: false, author: { id: "u_bob", name: "Bob" } }) + expect(prompts[0]).not.toHaveProperty("noReply") + expect(result).toEqual({ stopReason: "end_turn", userMessageId: "msg_user_ses_a_1700000000000_n2", _meta: {} }) + } finally { + events.push = pushed + } }) it("documentor: malformed documentor containers are ignored", async () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7fd9de551e7a..b7958dcc17dd 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1555,6 +1555,34 @@ it.instance("documentor: an empty prompt on a fresh session keeps upstream behav }), ) +it.instance("documentor: a noReply prompt commits a pending revert", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const seeded = yield* seed(chat.id, { finish: "stop" }) + yield* sessions.setRevert({ + sessionID: chat.id, + revert: { messageID: seeded.user.id }, + summary: { additions: 0, deletions: 0, files: 0 }, + }) + + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + parts: [{ type: "text", text: "a side remark" }], + }) + + const ids = (yield* sessions.messages({ sessionID: chat.id })).map((msg) => msg.info.id) + expect(ids).toEqual([result.info.id]) + expect((yield* sessions.get(chat.id)).revert).toBeUndefined() + expect(yield* llm.calls).toBe(0) + }), +) + it.instance("documentor: an empty prompt while a loop runs joins that loop", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)