diff --git a/documentor/FORK.md b/documentor/FORK.md index 3fac724f655e..483b818ee3d8 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -25,11 +25,17 @@ 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/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/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` | Generated by `packages/sdk/js/script/build.ts` from the route 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 routes above | | `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 | -| Fork cases in `packages/opencode/test/acp/event.test.ts`, `test/acp/service-session.test.ts`, `test/session/prompt.test.ts` and `test/session/session.test.ts` | Tests named `documentor: …` | They pin the three rows above; a rebase keeps them | +| `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 | | `packages/core/src/integration.ts` | An OAuth attempt's credential is stored before the attempt reads as complete, inside one update of the attempts map | The attempt was marked complete first and the credential written after. On SQLite nothing could look in between; on a network database a caller that saw "complete" could list no credential | | `patches/effect@4.0.0-beta.83.patch` | Adds Effect's own fix for re-entrant interruption, first released in `4.0.0-beta.100` | A fiber that woke another fiber which interrupted it synchronously had its continuation stack corrupted once its cleanup awaited anything. With Postgres that leaked a pooled connection. Drop these hunks when upstream moves to `beta.100` or later | | `turbo.json` | `OPENCODE_TEST_DATABASE_URL` passes through to every task; `@opencode-ai/effect-drizzle-pg#test` is a task | turbo strips variables it is not told about | @@ -122,7 +128,7 @@ must produce the same `migration` journal; `documentor/scripts/binary-smoke.sh`. 6. Read `upstream-test-exceptions.md` against the rebased tests. A test that upstream rewrote may no longer need its exception. -7. Run `bun test test/acp/event.test.ts test/acp/service-session.test.ts test/session/prompt.test.ts -t "documentor:"` in `packages/opencode`. These pin the ACP changes DocuMentor's queued messages depend on; a failure means upstream moved the code they patch. +7. Run `bun test 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 -t "documentor:"` in `packages/opencode`. These pin the ACP and session changes DocuMentor's queued messages and project discussions depend on; a failure means upstream moved the code they patch. A new upstream test that fails only on Postgres is most often one of two things. Either it reads state the instant a call returns, before a background diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index b4ce31e5f09a..973a70ec13ef 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -42,6 +42,7 @@ export { ToolStatePending, ToolStateRunning, User, + UserAuthor, WithParts, } from "@opencode-ai/schema/session-v1" diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index abc90503d3f5..97c2e0c916e4 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -35,9 +35,20 @@ type GlobalEventStream = { * `_meta.opencode` on every content and tool update: the assistant message the * update belongs to, and the user message that assistant answers. A client that * sends a prompt while a turn runs reads from it the moment the agent starts - * answering that prompt. + * answering that prompt. On a replayed user message `messageId` is that user + * message and `parentId` is `null`; it also names its author, carries + * `answerable: false` when it is context no turn answers, and carries its text + * part's metadata. */ -export type OpencodeUpdateMeta = { opencode: { messageId: string; parentId: string | null } } +export type OpencodeUpdateMeta = { + opencode: { + messageId: string + parentId: string | null + author?: { id: string; name: string } + answerable?: boolean + partMetadata?: Record + } +} export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) { const subscription = new Subscription(input) @@ -144,6 +155,7 @@ export class Subscription { : message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + const user = message.info.role === "user" ? message.info : undefined for (const chunk of partsToContentChunks([part as ReplayPart])) { await this.input.connection.sessionUpdate({ @@ -152,16 +164,15 @@ export class Subscription { sessionUpdate, messageId: part.type === "reasoning" ? part.id : message.info.id, ...chunk, - ...(sessionUpdate === "user_message_chunk" - ? {} - : { - _meta: { - opencode: { - messageId: message.info.id, - parentId: message.info.role === "assistant" ? (message.info.parentID ?? null) : null, - }, - } satisfies OpencodeUpdateMeta, - }), + _meta: { + opencode: { + messageId: message.info.id, + parentId: message.info.role === "assistant" ? (message.info.parentID ?? null) : null, + ...(user?.author ? { author: user.author } : {}), + ...(user?.answerable === false ? { answerable: false } : {}), + ...(user && part.type === "text" && part.metadata ? { partMetadata: part.metadata } : {}), + }, + } satisfies OpencodeUpdateMeta, }, }) } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index ce0673009dec..75e579de9762 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -167,6 +167,7 @@ export function make(input: { const selected = selectDefaultModel(snapshot) const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined + const documentor = documentorMeta(params._meta) const created = yield* profiledRequest( "acp.newSession.session.create", () => @@ -180,6 +181,7 @@ export function make(input: { id: selected.modelID, ...(variant ? { variant } : {}), }, + ...(documentor.metadata ? { metadata: documentor.metadata } : {}), }, { throwOnError: true }, ), @@ -520,29 +522,40 @@ export function make(input: { const parts = promptContentToParts(params.prompt) const command = detectSlashCommand(parts) const messageID = parts.length > 0 ? opencodeMessageID(params.messageId) : undefined + const documentor = documentorMeta(params._meta) + const silent = documentor.noReply === true || documentor.answerable === false if (!command) { - const response = yield* request( - () => - runUntilIdle(current.id, () => - input.sdk.session.prompt( - { - sessionID: current.id, - ...(messageID ? { messageID } : {}), - model: { - providerID: selected.providerID, - modelID: selected.modelID, - }, - ...(variant ? { variant } : {}), - parts, - ...(modeId ? { agent: modeId } : {}), - directory: current.cwd, - }, - { throwOnError: true }, - ), - ), - "session", - ) + const send = () => + input.sdk.session.prompt( + { + sessionID: current.id, + ...(messageID ? { messageID } : {}), + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts: withPartMetadata(parts, documentor.partMetadata), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + ...(documentor.author ? { author: documentor.author } : {}), + ...(documentor.answerable === false ? { answerable: false } : {}), + ...(documentor.noReply ? { noReply: true } : {}), + ...(documentor.system ? { system: documentor.system } : {}), + }, + { throwOnError: true }, + ) + if (silent) { + // No loop runs, so no idle event follows; the stored user message is the whole result. + yield* request(send, "session") + return { + stopReason: "end_turn" as const, + ...(params.messageId ? { userMessageId: params.messageId } : {}), + _meta: {}, + } + } + const response = yield* request(() => runUntilIdle(current.id, send), "session") yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) return yield* promptResponse(response.info, params.messageId) } @@ -859,6 +872,49 @@ function opencodeMessageID(messageId: string | null | undefined) { return typeof messageId === "string" && messageId.startsWith("msg") ? messageId : undefined } +// DocuMentor's additions to a prompt and to a new session travel in ACP's `_meta`, under one key, so that +// another client's `_meta` is never read as ours. Each field is taken only when it has the expected shape. +export type DocumentorMeta = { + author?: { id: string; name: string } + answerable?: false + noReply?: true + system?: string + /** Becomes the `metadata` of the prompt's first text part (DocuMentor keeps a message's mentions there). */ + partMetadata?: Record + metadata?: Record +} + +export function documentorMeta(meta: unknown): DocumentorMeta { + const root = isRecord(meta) ? meta["documentor"] : undefined + if (!isRecord(root)) return {} + const out: DocumentorMeta = {} + const author = root["author"] + if (isRecord(author) && typeof author["id"] === "string" && typeof author["name"] === "string") { + out.author = { id: author["id"], name: author["name"] } + } + if (root["answerable"] === false) out.answerable = false + if (root["noReply"] === true) out.noReply = true + if (typeof root["system"] === "string" && root["system"] !== "") out.system = root["system"] + if (isRecord(root["partMetadata"])) out.partMetadata = root["partMetadata"] + if (isRecord(root["metadata"])) out.metadata = root["metadata"] + return out +} + +/** The prompt's parts with `partMetadata` on the first text part; the input is not changed. */ +export function withPartMetadata( + parts: T[], + partMetadata: Record | undefined, +): T[] { + if (!partMetadata) return parts + const index = parts.findIndex((part) => part.type === "text") + if (index === -1) return parts + return parts.map((part, i) => (i === index ? { ...part, metadata: partMetadata } : part)) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + const promptResponse = Effect.fn("ACP.promptResponse")(function* ( info: AssistantInfo, messageId: string | null | undefined, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..aaaa0e6dedd1 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -51,7 +51,7 @@ type CompletedCompaction = { const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` -const serialize = (message: SessionV1.WithParts) => { +const serialize = (message: SessionV1.WithParts, attributeAuthors: boolean) => { if (message.info.role === "user") { const text = message.parts .filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored) @@ -61,7 +61,8 @@ const serialize = (message: SessionV1.WithParts) => { const files = message.parts.flatMap((part) => part.type === "file" ? [`[Attached ${part.mime}: ${part.filename ?? "file"}]`] : [], ) - return [...(text ? [`[User]: ${text}`] : []), ...files].join("\n") + const prefix = attributeAuthors ? MessageV2.authorPrefix(message.info.author) : "" + return [...(text ? [`[User]: ${prefix}${text}`] : []), ...files].join("\n") } return message.parts .flatMap((part) => { @@ -124,6 +125,7 @@ function turns(messages: SessionV1.WithParts[]) { for (let i = 0; i < messages.length; i++) { const msg = messages[i] if (msg.info.role !== "user") continue + if (msg.info.answerable === false) continue if (msg.parts.some((part) => part.type === "compaction")) continue result.push({ start: i, @@ -287,7 +289,7 @@ const layer = Layer.effect( loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { const msg = msgs[msgIndex] - if (msg.info.role === "user") turns++ + if (msg.info.role === "user" && msg.info.answerable !== false) turns++ if (turns < 2) continue if (msg.info.role === "assistant" && msg.info.summary) break loop for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { @@ -328,6 +330,8 @@ const layer = Layer.effect( throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } const userMessage = parent.info + const sessionInfo = yield* session.get(input.sessionID).pipe(Effect.orDie) + const attributeAuthors = MessageV2.isDiscussion(sessionInfo.metadata) const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction") let messages = input.messages @@ -377,7 +381,10 @@ const layer = Layer.effect( ) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") + const conversation = msgs + .map((m) => serialize(m, attributeAuthors)) + .filter(Boolean) + .join("\n\n") const nextPrompt = compacting.prompt ?? [ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2bd0e5171589..789b2e4216dd 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -128,10 +128,24 @@ function providerMeta(metadata: Record | undefined) { return Object.keys(rest).length > 0 ? rest : undefined } +/** `"Name: "`, the name flattened to one line so a name cannot pose as a second speaker. */ +export function authorPrefix(author: SessionV1.UserAuthor | undefined): string { + const name = author?.name.replace(/\s+/g, " ").trim() ?? "" + return name ? `${name}: ` : "" +} + +/** A discussion is a session DocuMentor marks so; its model input carries who said what. */ +export function isDiscussion(metadata: Record | undefined): boolean { + const documentor = metadata?.["documentor"] + return ( + typeof documentor === "object" && documentor !== null && (documentor as { kind?: unknown }).kind === "discussion" + ) +} + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number; attributeAuthors?: boolean }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -205,13 +219,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } + const prefix = options?.attributeAuthors ? authorPrefix(msg.info.author) : "" + let attributed = false for (const part of msg.parts) { // User message parts should never be empty - if (part.type === "text" && !part.ignored && part.text !== "") + if (part.type === "text" && !part.ignored && part.text !== "") { + const own = prefix !== "" && !attributed && !part.synthetic + if (own) attributed = true userMessage.parts.push({ type: "text", - text: part.text, + text: own ? `${prefix}${part.text}` : part.text, }) + } // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (options?.stripMedia && isMedia(part.mime)) { @@ -421,7 +440,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number; attributeAuthors?: boolean }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options)) } @@ -589,7 +608,8 @@ export function latest(msgs: WithParts[]) { let finished: Assistant | undefined for (const msg of msgs) { const info = msg.info - if (info.role === "user" && isAfter(info, user)) user = info + // `user` is the message a turn answers; one marked unanswerable is context, never that. + if (info.role === "user" && info.answerable !== false && isAfter(info, user)) user = info if (info.role === "assistant" && isAfter(info, assistant)) assistant = info if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2095f585548f..11a756211615 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -667,6 +667,8 @@ const layer = Layer.effect( }, system: input.system, format: input.format, + ...(input.author ? { author: input.author } : {}), + ...(input.answerable === false ? { answerable: false } : {}), } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -1053,7 +1055,12 @@ const layer = Layer.effect( )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) - if (input.parts.length === 0 && input.noReply !== true && (yield* hasUserMessage(input.sessionID))) { + if ( + input.parts.length === 0 && + input.noReply !== true && + input.answerable !== false && + (yield* hasUserMessage(input.sessionID)) + ) { // A prompt with no parts resumes the session instead of storing an empty user message. The loop runs over // the messages already stored, so user messages that arrived during a cancelled step are answered, and a // session with nothing left to answer returns its last assistant without calling the model. @@ -1071,7 +1078,8 @@ const layer = Layer.effect( yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) } - if (input.noReply === true) return message + // An unanswerable message is context for a later turn, never the start of one. + if (input.noReply === true || input.answerable === false) return message return yield* loop({ sessionID: input.sessionID }) }) @@ -1105,7 +1113,13 @@ const layer = Layer.effect( const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs) - if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + if (!lastUser) { + // Nothing to answer: every user message is unanswerable (or there is none). Leave + // through the normal exit, which returns the newest assistant message (or the newest + // message) without a step; the runner sets the session idle when the loop returns. + yield* Effect.logInfo("exiting loop, nothing answerable", { "session.id": sessionID }) + break + } const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, @@ -1269,7 +1283,9 @@ const layer = Layer.effect( sys.environment(model), instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), - MessageV2.toModelMessagesEffect(msgs, model), + MessageV2.toModelMessagesEffect(msgs, model, { + attributeAuthors: MessageV2.isDiscussion(session.metadata), + }), ]) const system = [ ...env, @@ -1512,6 +1528,8 @@ export const PromptInput = Schema.Struct({ model: Schema.optional(ModelRef), agent: Schema.optional(Schema.String), noReply: Schema.optional(Schema.Boolean), + author: Schema.optional(SessionV1.UserAuthor), + answerable: Schema.optional(Schema.Boolean), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ description: "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 935d498c07a3..4c83d3a99bfb 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -840,6 +840,101 @@ describe("documentor: acp update message meta", () => { expect(harness.calls.message).toBe(0) }) + it("documentor: replays a user message with its author, answerable flag and part metadata", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_replay_user", cwd: "/workspace" })) + + await harness.subscription.replayMessage({ + info: { + id: "msg_user_1", + sessionID: "ses_replay_user", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + author: { id: "u_alice", name: "Alice" }, + answerable: false, + }, + parts: [ + { + id: "part_user_1", + sessionID: "ses_replay_user", + messageID: "msg_user_1", + type: "text", + text: "hello @Bob", + metadata: { mentions: [{ kind: "user", id: "u_bob", name: "Bob" }] }, + }, + ], + } as unknown as SessionMessageResponse) + + expect(harness.updates.map((update) => update.update)).toEqual([ + { + sessionUpdate: "user_message_chunk", + messageId: "msg_user_1", + content: { type: "text", text: "hello @Bob" }, + _meta: { + opencode: { + messageId: "msg_user_1", + parentId: null, + author: { id: "u_alice", name: "Alice" }, + answerable: false, + partMetadata: { mentions: [{ kind: "user", id: "u_bob", name: "Bob" }] }, + }, + }, + }, + ]) + }) + + it("documentor: replays a user message without the new fields with the bare meta", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_replay_plain", cwd: "/workspace" })) + + await harness.subscription.replayMessage({ + info: { + id: "msg_user_2", + sessionID: "ses_replay_plain", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }, + parts: [{ id: "part_user_2", sessionID: "ses_replay_plain", messageID: "msg_user_2", type: "text", text: "hi" }], + } as unknown as SessionMessageResponse) + + expect(harness.updates[0]?.update).toEqual({ + sessionUpdate: "user_message_chunk", + messageId: "msg_user_2", + content: { type: "text", text: "hi" }, + _meta: { opencode: { messageId: "msg_user_2", parentId: null } }, + }) + }) + + it("documentor: replays an assistant text chunk without its part metadata", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_replay_answer", cwd: "/workspace" })) + + await harness.subscription.replayMessage({ + info: { id: "msg_answer_3", sessionID: "ses_replay_answer", role: "assistant", parentID: "msg_user_3" }, + parts: [ + { + id: "part_answer_3", + sessionID: "ses_replay_answer", + messageID: "msg_answer_3", + type: "text", + text: "hi there", + metadata: { openai: { itemId: "item_1" } }, + }, + ], + } as unknown as SessionMessageResponse) + + expect(harness.updates[0]?.update).toEqual({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_answer_3", + content: { type: "text", text: "hi there" }, + _meta: { opencode: { messageId: "msg_answer_3", parentId: "msg_user_3" } }, + }) + }) + it("documentor: names the assistant message, not the reasoning part, on thought chunks", async () => { const harness = createHarness() await createKnownSession(harness.session, "ses_meta", { diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index e2eca2d2c0ab..c15e0a608ac1 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -208,7 +208,7 @@ describe("ACP service sessions", () => { model?: { id: string; providerID: string; variant?: string } } }> - prompt?: (input: unknown) => Promise<{ data: { info: ReturnType } }> + prompt?: (input: unknown) => Promise<{ data: { info: unknown } }> sessionUpdate?: (update: SessionNotification) => Promise }, ) => { @@ -217,6 +217,7 @@ describe("ACP service sessions", () => { const aborts: string[] = [] const forks: string[] = [] const prompts: unknown[] = [] + const creates: unknown[] = [] const commands: unknown[] = [] const summarizes: unknown[] = [] const usageUpdates: string[] = [] @@ -256,7 +257,10 @@ describe("ACP service sessions", () => { }), }, session: { - create: () => Promise.resolve({ data: { id: "ses_new" } }), + create: (input: unknown) => { + creates.push(input) + return Promise.resolve({ data: { id: "ses_new" } }) + }, get: options?.get ?? (() => Promise.resolve({ data: { id: "ses_loaded" } })), list: (input: { directory?: string }) => Promise.resolve({ @@ -340,6 +344,7 @@ describe("ACP service sessions", () => { aborts, forks, prompts, + creates, commands, summarizes, usageUpdates, @@ -566,6 +571,7 @@ describe("ACP service sessions", () => { sessionUpdate: "user_message_chunk", messageId: "msg_user", content: { type: "text", text: "hello" }, + _meta: { opencode: { messageId: "msg_user", parentId: null } }, }, { sessionUpdate: "agent_message_chunk", @@ -636,7 +642,9 @@ describe("ACP service sessions", () => { await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) const replayed = updates.map((item) => item.update) - expect(replayed.find((item) => item.sessionUpdate === "user_message_chunk")).not.toHaveProperty("_meta") + expect(replayed.find((item) => item.sessionUpdate === "user_message_chunk")?._meta).toEqual({ + opencode: { messageId: "msg_user", parentId: null }, + }) expect(replayed.find((item) => item.sessionUpdate === "agent_message_chunk")).toMatchObject({ _meta: { opencode: { messageId: "msg_assistant", parentId: "msg_user" } }, }) @@ -1468,6 +1476,175 @@ describe("ACP service sessions", () => { expect(prompts[0]).not.toHaveProperty("messageID") }) + it("documentor: forwards the documentor prompt meta to the v1 prompt", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + messageId: "msg_user_ses_a_1700000000000_m1", + prompt: [{ type: "text", text: "@mentor what do you think?" }], + _meta: { + documentor: { + author: { id: "u_alice", name: "Alice" }, + system: "This is a discussion among Alice and Bob.", + partMetadata: { mentions: [{ kind: "mentor" }] }, + }, + }, + }), + ) + + expect(prompts).toHaveLength(1) + expect(prompts[0]).toMatchObject({ + sessionID: session.sessionId, + messageID: "msg_user_ses_a_1700000000000_m1", + author: { id: "u_alice", name: "Alice" }, + system: "This is a discussion among Alice and Bob.", + parts: [{ type: "text", text: "@mentor what do you think?", metadata: { mentions: [{ kind: "mentor" }] } }], + }) + expect(prompts[0]).not.toHaveProperty("noReply") + expect(prompts[0]).not.toHaveProperty("answerable") + }) + + it("documentor: part metadata lands on the first text part only", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + _meta: { documentor: { partMetadata: { mentions: [{ kind: "all" }] } } }, + }), + ) + + expect((prompts[0] as { parts: unknown[] }).parts).toEqual([ + { type: "text", text: "first", metadata: { mentions: [{ kind: "all" }] } }, + { type: "text", text: "second" }, + ]) + }) + + it("documentor: a noReply prompt returns end_turn without waiting for idle", async () => { + // The fake prompt never pushes an idle event: a regression that waits for idle times out here. + const { service, prompts, events } = makeService([], { + prompt: () => Promise.resolve({ data: { info: userInfo("msg_user_ses_a_1700000000000_n1") } }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + 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 + + 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: {} }) + }) + + it("documentor: malformed documentor meta is ignored", async () => { + const { service, prompts } = 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: "hello" }], + _meta: { documentor: { author: "Alice", answerable: "no", noReply: "yes", system: 42 } }, + }), + ) + + expect(result.stopReason).toBe("end_turn") + expect(prompts[0]).not.toHaveProperty("author") + expect(prompts[0]).not.toHaveProperty("answerable") + expect(prompts[0]).not.toHaveProperty("noReply") + expect(prompts[0]).not.toHaveProperty("system") + }) + + it("documentor: answerable false alone returns end_turn without waiting for idle", async () => { + // The fake prompt never pushes an idle event: a regression that waits for idle times out here. + const { service, prompts, events } = makeService([], { + prompt: () => Promise.resolve({ data: { info: userInfo("msg_user_ses_a_1700000000000_n2") } }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + 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 + + 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: {} }) + }) + + it("documentor: malformed documentor containers are ignored", async () => { + const metas: Record[] = [ + { documentor: null }, + { documentor: [] }, + { documentor: { partMetadata: [] } }, + { documentor: { system: "" } }, + ] + for (const _meta of metas) { + const { service, prompts } = 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: "hello" }], _meta }), + ) + + expect(result.stopReason).toBe("end_turn") + expect(prompts[0]).not.toHaveProperty("author") + expect(prompts[0]).not.toHaveProperty("answerable") + expect(prompts[0]).not.toHaveProperty("noReply") + expect(prompts[0]).not.toHaveProperty("system") + expect((prompts[0] as { parts: unknown[] }).parts).toEqual([{ type: "text", text: "hello" }]) + } + + const { service, creates } = makeService() + await Effect.runPromise( + service.newSession({ cwd: "/workspace", mcpServers: [], _meta: { documentor: { metadata: "x" } } }), + ) + expect(creates[0]).not.toHaveProperty("metadata") + }) + + it("documentor: forwards session metadata from the new-session meta", async () => { + const { service, creates } = makeService() + + await Effect.runPromise( + service.newSession({ + cwd: "/workspace", + mcpServers: [], + _meta: { documentor: { metadata: { documentor: { kind: "discussion" } } } }, + }), + ) + await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(creates).toHaveLength(2) + expect(creates[0]).toMatchObject({ metadata: { documentor: { kind: "discussion" } } }) + expect(creates[1]).not.toHaveProperty("metadata") + }) + it("waits for queued session updates before returning end_turn", async () => { const called = deferred() const response = deferred<{ data: { info: ReturnType } }>() @@ -1732,8 +1909,75 @@ describe("ACP service sessions", () => { expect(error.code).toBe(-32000) }) + + it("documentor: a failing silent prompt surfaces the same request error as a normal one", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const failing = ACPService.make({ + sdk: { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: () => Promise.resolve({ data: { id: session.sessionId } }), + list: () => Promise.resolve({ data: [] }), + prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient, + usage: UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit: () => Effect.succeed(128000), + sendUpdate: () => Effect.void, + }), + }) + await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] })) + const send = (_meta?: Record) => + Effect.runPromise( + failing + .prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + ...(_meta ? { _meta } : {}), + }) + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), + ) + + const normal = await send() + const silent = await send({ documentor: { noReply: true } }) + + expect(silent.code).toBe(-32000) + expect({ code: silent.code, message: silent.message, data: silent.data }).toEqual({ + code: normal.code, + message: normal.message, + data: normal.data, + }) + }) }) +function userInfo(id: string) { + return { + id, + sessionID: "ses_new", + role: "user" as const, + time: { created: Date.now() }, + agent: "build", + model: { providerID, modelID }, + } +} + function assistantInfo( tokens: UsageService.AssistantTokenCost["tokens"], error?: AssistantMessage["error"], diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..98b2ea301ea4 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -110,6 +110,35 @@ function createUserMessage(sessionID: SessionID, text: string) { }) } +function createAuthoredUserMessage( + sessionID: SessionID, + text: string, + author: SessionV1.UserAuthor, + answerable = true, +) { + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + author, + ...(answerable ? {} : { answerable: false }), + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }) +} + function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { return SessionNs.Service.use((ssn) => ssn.updateMessage({ @@ -809,6 +838,72 @@ describe("session.compaction.prune", () => { }), ), ) + + it.live( + "documentor: side remarks after a tool call do not count as turns", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const a = yield* createUserMessage(info.id, "first") + const b = yield* createAssistantMessage(info.id, a.id, dir) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: b.id, + sessionID: info.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "x".repeat(200_000), + title: "done", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + for (const text of ["a side remark", "another side remark"]) { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: ref, + answerable: false, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "text", + text, + }) + } + yield* createUserMessage(info.id, "@mentor second") + + yield* compact.prune({ sessionID: info.id }) + + // Prune leaves the two newest turns alone; side remarks are not turns, so the tool call is still in them. + const msgs = yield* ssn.messages({ sessionID: info.id }) + const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") + expect(part?.type).toBe("tool") + expect(part?.state.status).toBe("completed") + if (part?.type === "tool" && part.state.status === "completed") { + expect(part.state.time.compacted).toBeUndefined() + } + }), + + { + config: { + compaction: { prune: true }, + }, + }, + ), + ) }) describe("session.compaction.process", () => { @@ -961,6 +1056,37 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) + itCompaction.instance( + "documentor: side remarks do not change which tail is retained", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({ metadata: { documentor: { kind: "discussion" } } }) + const alice = { id: "u_alice", name: "Alice" } + const bob = { id: "u_bob", name: "Bob" } + yield* createAuthoredUserMessage(session.id, "@mentor first", alice) + const keep = yield* createAuthoredUserMessage(session.id, "@mentor second", alice) + yield* createAuthoredUserMessage(session.id, "a side remark", bob, false) + yield* createAuthoredUserMessage(session.id, "another side remark", alice, false) + yield* createAuthoredUserMessage(session.id, "@mentor third", bob) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + // Two answerable turns are kept, as in "persists tail_start_id for retained recent turns". + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), + ) + itCompaction.instance( "shrinks retained tail to fit preserve token budget", Effect.gen(function* () { @@ -1509,6 +1635,44 @@ describe("session.compaction.process", () => { { git: true }, ) + for (const [title, metadata, expected] of [ + [ + "documentor: a discussion's compaction request names who said what", + { documentor: { kind: "discussion" } }, + ["[User]: Alice: I think chapter 3 is too long", "[User]: Bob: Agreed, the middle drags"], + ], + [ + "documentor: an ordinary session's compaction request carries no author names", + undefined, + ["[User]: I think chapter 3 is too long", "[User]: Agreed, the middle drags"], + ], + ] as const) { + itCompaction.instance(title, () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create(metadata ? { metadata } : {}) + yield* createAuthoredUserMessage(session.id, "I think chapter 3 is too long", { id: "u_alice", name: "Alice" }) + yield* createAuthoredUserMessage(session.id, "Agreed, the middle drags", { id: "u_bob", name: "Bob" }) + yield* createCompactionMarker(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + for (const line of expected) expect(captured).toContain(line) + if (!metadata) { + expect(captured).not.toContain("Alice:") + expect(captured).not.toContain("Bob:") + } + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 0 }) })) + }) + } + itCompaction.instance( "serializes repeated compaction history as one user message", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 734a30e42454..9f3385fa8e6e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1361,6 +1361,77 @@ describe("session.message-v2.toModelMessage", () => { const texts = (result[0].content as any[]).filter((p) => p.type === "text") expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) }) + + test("documentor: attributes a user message to its author when asked", async () => { + const input: SessionV1.WithParts[] = [ + { + info: { ...userInfo("m-alice"), author: { id: "u_alice", name: "Alice" } } as SessionV1.User, + parts: [{ ...basePart("m-alice", "p1"), type: "text", text: "hello" }] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { attributeAuthors: true })).toStrictEqual([ + { role: "user", content: [{ type: "text", text: "Alice: hello" }] }, + ]) + }) + + test("documentor: leaves the text alone when attribution is off or there is no author", async () => { + const withAuthor: SessionV1.WithParts[] = [ + { + info: { ...userInfo("m-alice"), author: { id: "u_alice", name: "Alice" } } as SessionV1.User, + parts: [{ ...basePart("m-alice", "p1"), type: "text", text: "hello" }] as SessionV1.Part[], + }, + ] + const withoutAuthor: SessionV1.WithParts[] = [ + { + info: userInfo("m-anon"), + parts: [{ ...basePart("m-anon", "p1"), type: "text", text: "hello" }] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(withAuthor, model)).toStrictEqual([ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]) + expect(await MessageV2.toModelMessages(withoutAuthor, model, { attributeAuthors: true })).toStrictEqual([ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]) + }) + + test("documentor: the prefix goes on the first non-synthetic text part only", async () => { + const input: SessionV1.WithParts[] = [ + { + info: { ...userInfo("m-alice"), author: { id: "u_alice", name: "Alice" } } as SessionV1.User, + parts: [ + { ...basePart("m-alice", "p0"), type: "text", text: "", synthetic: true }, + { ...basePart("m-alice", "p1"), type: "text", text: "first" }, + { ...basePart("m-alice", "p2"), type: "text", text: "second" }, + ] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { attributeAuthors: true })).toStrictEqual([ + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Alice: first" }, + { type: "text", text: "second" }, + ], + }, + ]) + }) + + test("documentor: author names are flattened to one line", () => { + expect(MessageV2.authorPrefix({ id: "u", name: "Dr. Müller:\nPädiatrie " })).toBe("Dr. Müller: Pädiatrie: ") + expect(MessageV2.authorPrefix({ id: "u", name: " " })).toBe("") + }) + + test("documentor: a session is a discussion by its metadata", () => { + expect(MessageV2.isDiscussion({ documentor: { kind: "discussion" } })).toBe(true) + expect(MessageV2.isDiscussion({ documentor: { kind: "chat" } })).toBe(false) + expect(MessageV2.isDiscussion({})).toBe(false) + expect(MessageV2.isDiscussion(undefined)).toBe(false) + }) }) describe("session.message-v2.fromError", () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 14fd587c1154..7fd9de551e7a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1584,6 +1584,220 @@ it.instance("documentor: an empty prompt while a loop runs joins that loop", () }), ) +it.instance("documentor: a prompt stores its author and whether it is answerable", () => + 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" }) + yield* llm.text("should not be requested") + + const stored = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + answerable: false, + author: { id: "u_alice", name: "Alice" }, + parts: [{ type: "text", text: "hello everyone" }], + }) + + expect(stored.info.role).toBe("user") + const msgs = yield* sessions.messages({ sessionID: chat.id }) + expect(msgs).toHaveLength(1) + const info = msgs[0]!.info + if (info.role !== "user") throw new Error("expected a user message") + expect(info.author).toEqual({ id: "u_alice", name: "Alice" }) + expect(info.answerable).toBe(false) + expect(yield* llm.hits).toHaveLength(0) + }), +) + +it.instance("documentor: a prompt without the new fields stores neither", () => + 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" }) + yield* llm.text("reply") + + yield* prompt.prompt({ sessionID: chat.id, agent: "build", model: ref, parts: [{ type: "text", text: "hi" }] }) + + const users = (yield* sessions.messages({ sessionID: chat.id })).filter((msg) => msg.info.role === "user") + expect(users).toHaveLength(1) + const info = users[0]!.info + if (info.role !== "user") throw new Error("expected a user message") + expect(info).not.toHaveProperty("author") + expect(info).not.toHaveProperty("answerable") + }), +) + +it.instance("documentor: an unanswerable prompt stores the message and runs no step", () => + 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" }) + yield* llm.text("should not be requested") + + // answerable: false without noReply: the loop must not start. + const stored = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + answerable: false, + parts: [{ type: "text", text: "just a note" }], + }) + + expect(stored.info.role).toBe("user") + expect(yield* llm.hits).toHaveLength(0) + const msgs = yield* sessions.messages({ sessionID: chat.id }) + expect(msgs).toHaveLength(1) + const info = msgs[0]!.info + if (info.role !== "user") throw new Error("expected a user message") + expect(info.answerable).toBe(false) + }), +) + +it.instance("documentor: an unanswerable prompt with no parts stores a row and runs no step", () => + 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" }) + yield* seed(chat.id, { finish: "stop" }) + yield* llm.text("should not be requested") + + const stored = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + answerable: false, + parts: [], + }) + + expect(stored.info.role).toBe("user") + expect(yield* llm.hits).toHaveLength(0) + }), +) + +it.instance("documentor: an unanswerable message stored mid-turn does not extend the turn", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const gate = yield* Deferred.make() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.hold("first", deferredAsPromise(gate)) + // Queued so a regression that takes a second step returns instead of hanging. + yield* llm.text("must not be requested") + + const a = yield* prompt + .prompt({ sessionID: chat.id, agent: "build", model: ref, parts: [{ type: "text", text: "first" }] }) + .pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + const id = MessageID.ascending() + yield* prompt.prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + noReply: true, + answerable: false, + author: { id: "u_bob", name: "Bob" }, + parts: [{ type: "text", text: "a side remark while the mentor works" }], + }) + yield* Deferred.succeed(gate, void 0) + + const ea = yield* Fiber.await(a) + expect(Exit.isSuccess(ea)).toBe(true) + // The upstream case "prompt submitted during an active run is included in the next LLM input" is the + // control: the same second message without answerable: false makes this 2. + expect(yield* llm.calls).toBe(1) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(1) + const only = assistants[0]!.info + if (only.role !== "assistant") throw new Error("expected an assistant message") + expect(only.parentID).not.toBe(id) + expect(msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)).toBe(true) + }), +) + +it.instance("documentor: only unanswerable messages: an empty prompt calls no model", () => + 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" }) + yield* llm.text("must not be requested") + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + answerable: false, + parts: [{ type: "text", text: "note one" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + answerable: false, + parts: [{ type: "text", text: "note two" }], + }) + + // The resume path DocuMentor uses after a cancel: no parts, no noReply. + const result = yield* prompt.prompt({ sessionID: chat.id, agent: "build", model: ref, parts: [] }) + + expect(yield* llm.hits).toHaveLength(0) + expect(result.info.role).toBe("user") + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(2) + }), +) + +it.instance("documentor: an answerable prompt after unanswerable ones is the one answered", () => + 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" }) + yield* llm.text("the mentor answers") + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + answerable: false, + parts: [{ type: "text", text: "note one" }], + }) + const asked = MessageID.ascending() + const result = yield* prompt.prompt({ + sessionID: chat.id, + messageID: asked, + agent: "build", + model: ref, + parts: [{ type: "text", text: "@mentor what do you think?" }], + }) + + if (result.info.role !== "assistant") throw new Error("expected an assistant message") + expect(result.info.parentID).toBe(asked) + expect(yield* llm.calls).toBe(1) + const inputs = yield* llm.inputs + const messages = inputs.at(-1)?.messages + if (!Array.isArray(messages)) throw new Error("expected LLM messages") + const roles = messages.map((m: { role: string; content: unknown }) => m.role).filter((role) => role !== "system") + expect(roles).toEqual(["user", "user"]) + }), +) + it.instance("assertNotBusy fails with BusyError when loop running", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) @@ -2556,3 +2770,45 @@ noLLMServer.instance( }), 30_000, ) + +it.instance("documentor: a discussion's turn sees every human message prefixed with its author", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const discussion = yield* sessions.create({ title: "Chapter 3", metadata: { documentor: { kind: "discussion" } } }) + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.text("the mentor answers the group") + yield* llm.text("the mentor answers the chat") + + const say = (sessionID: SessionID, name: string, text: string, answerable: boolean) => + prompt.prompt({ + sessionID, + agent: "build", + model: ref, + ...(answerable ? {} : { noReply: true, answerable: false }), + author: { id: `u_${name.toLowerCase()}`, name }, + parts: [{ type: "text", text }], + }) + + yield* say(discussion.id, "Alice", "I think chapter 3 is too long", false) + yield* say(discussion.id, "Bob", "Agreed, the middle drags", false) + yield* say(discussion.id, "Alice", "@mentor what would you cut?", true) + + yield* say(chat.id, "Alice", "summarise chapter 3", true) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + const texts = (i: number) => + (inputs[i]!.messages as { role: string; content: string | { type: string; text?: string }[] }[]) + .filter((m) => m.role === "user") + .map((m) => (typeof m.content === "string" ? m.content : m.content.map((c) => c.text ?? "").join(""))) + expect(texts(0)).toEqual([ + "Alice: I think chapter 3 is too long", + "Bob: Agreed, the middle drags", + "Alice: @mentor what would you cut?", + ]) + // The ordinary chat carries authors too, and sends the text unprefixed. + expect(texts(1)).toEqual(["summarise chapter 3"]) + }), +) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..e34353db20a2 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -329,6 +329,13 @@ const messageBase = { sessionID: partBase.sessionID, } +/** Who typed a user message, when the client that sent it says so. */ +export const UserAuthor = Schema.Struct({ + id: Schema.String, + name: Schema.String, +}).annotate({ identifier: "UserMessageAuthor" }) +export type UserAuthor = Schema.Schema.Type + export const User = Schema.Struct({ ...messageBase, role: Schema.Literal("user"), @@ -351,6 +358,13 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + author: Schema.optional(UserAuthor), + /** + * `false` means the loop never treats this message as the one being + * answered: it is read as context at the next step, but no turn runs or + * stays alive for it. Absent means `true`. + */ + answerable: Schema.optional(Schema.Boolean), }).annotate({ identifier: "UserMessage" }) export type User = Types.DeepMutable> diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 01f4483284c0..44b5ed515d27 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -263,6 +263,7 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + UserMessageAuthor, V2AgentListErrors, V2AgentListResponses, V2CommandListErrors, @@ -3753,6 +3754,8 @@ export class Session2 extends HeyApiClient { } agent?: string noReply?: boolean + author?: UserMessageAuthor + answerable?: boolean tools?: { [key: string]: boolean } @@ -3775,6 +3778,8 @@ export class Session2 extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, + { in: "body", key: "author" }, + { in: "body", key: "answerable" }, { in: "body", key: "tools" }, { in: "body", key: "format" }, { in: "body", key: "system" }, @@ -4106,6 +4111,8 @@ export class Session2 extends HeyApiClient { } agent?: string noReply?: boolean + author?: UserMessageAuthor + answerable?: boolean tools?: { [key: string]: boolean } @@ -4128,6 +4135,8 @@ export class Session2 extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, + { in: "body", key: "author" }, + { in: "body", key: "answerable" }, { in: "body", key: "tools" }, { in: "body", key: "format" }, { in: "body", key: "system" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a1f2bd854e31..05b3012637bc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -236,6 +236,11 @@ export type OutputFormatJsonSchema = { export type OutputFormat = OutputFormatText | OutputFormatJsonSchema +export type UserMessageAuthor = { + id: string + name: string +} + export type UserMessage = { id: string sessionID: string @@ -259,6 +264,8 @@ export type UserMessage = { tools?: { [key: string]: boolean } + author?: UserMessageAuthor + answerable?: boolean } export type ProviderAuthError = { @@ -9804,6 +9811,8 @@ export type SessionPromptData = { } agent?: string noReply?: boolean + author?: UserMessageAuthor + answerable?: boolean tools?: { [key: string]: boolean } @@ -10151,6 +10160,8 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean + author?: UserMessageAuthor + answerable?: boolean tools?: { [key: string]: boolean }