diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 78604fbf78b2..6b4ceb1a4664 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -582,7 +582,7 @@ export namespace MessageV2 { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; remind?: string }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -645,6 +645,7 @@ export namespace MessageV2 { if (msg.parts.length === 0) continue if (msg.info.role === "user") { + const wrapped = options?.remind && msg.info.id > options.remind const userMessage: UIMessage = { id: msg.info.id, role: "user", @@ -652,11 +653,23 @@ export namespace MessageV2 { } result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + if (part.type === "text" && !part.ignored) { + const text = + wrapped && !part.synthetic && part.text.trim() + ? [ + "", + "The user sent the following message:", + part.text, + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n") + : part.text userMessage.parts.push({ type: "text", - text: part.text, + 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)) { @@ -821,7 +834,7 @@ export namespace MessageV2 { export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; remind?: string }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options)) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e9bd5bcd5605..f2c66c7ace88 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1483,31 +1483,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id }) - if (step > 1 && lastFinished) { - for (const m of msgs) { - if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue - for (const p of m.parts) { - if (p.type !== "text" || p.ignored || p.synthetic) continue - if (!p.text.trim()) continue - p.text = [ - "", - "The user sent the following message:", - p.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } - } - } - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const [skills, env, instructions, modelMsgs] = yield* Effect.all([ Effect.promise(() => SystemPrompt.skills(agent)), Effect.promise(() => SystemPrompt.environment(model)), instruction.system().pipe(Effect.orDie), - Effect.promise(() => MessageV2.toModelMessages(msgs, model)), + Effect.promise(() => MessageV2.toModelMessages(msgs, model, { remind: lastFinished?.id })), ]) const system = [...env, ...(skills ? [skills] : []), ...instructions] const format = lastUser.format ?? { type: "text" as const } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7ec8..06db903b7b5e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -790,6 +790,330 @@ describe("session.message-v2.toModelMessage", () => { }) }) +describe("session.message-v2.toModelMessage remind option", () => { + test("wraps user text parts after remind threshold", async () => { + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo("a1", "u0"), + parts: [ + { + ...basePart("a1", "p0"), + type: "text", + text: "done", + }, + ] as MessageV2.Part[], + }, + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "queued msg", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a1" }) + expect(result).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + { + role: "user", + content: [ + { + type: "text", + text: [ + "", + "The user sent the following message:", + "queued msg", + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n"), + }, + ], + }, + ]) + }) + + test("does not wrap user messages at or before remind threshold", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("a0"), + parts: [ + { + ...basePart("a0", "p1"), + type: "text", + text: "old msg", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo("a1", "a0"), + parts: [ + { + ...basePart("a1", "pa"), + type: "text", + text: "reply", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a1" }) + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "old msg" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + ]) + }) + + test("does not wrap when remind is undefined", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + + test("skips synthetic parts when wrapping", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "queued", + }, + { + ...basePart("u1", "p2"), + type: "text", + text: "synthetic note", + synthetic: true, + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a0" }) + const parts = result[0].content as Array<{ type: string; text: string }> + const texts = parts.filter((p) => p.type === "text") + expect(texts[0].text).toContain("") + expect(texts[0].text).toContain("queued") + expect(texts[1].text).toBe("synthetic note") + }) + + test("skips ignored parts when wrapping", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "ignored text", + ignored: true, + }, + { + ...basePart("u1", "p2"), + type: "text", + text: "visible", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a0" }) + expect(result[0].content).toStrictEqual([ + { + type: "text", + text: [ + "", + "The user sent the following message:", + "visible", + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n"), + }, + ]) + }) + + test("skips empty text parts when wrapping", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: " ", + }, + { + ...basePart("u1", "p2"), + type: "text", + text: "real content", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a0" }) + const parts = result[0].content as Array<{ type: string; text: string }> + const texts = parts.filter((p) => p.type === "text") + expect(texts[0].text).toBe(" ") + expect(texts[1].text).toContain("") + }) + + test("produces identical output across multiple calls (cache consistency)", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "first msg", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo("a1", "u1"), + parts: [ + { + ...basePart("a1", "pa"), + type: "text", + text: "reply", + }, + ] as MessageV2.Part[], + }, + { + info: userInfo("u2"), + parts: [ + { + ...basePart("u2", "p2"), + type: "text", + text: "queued while busy", + }, + ] as MessageV2.Part[], + }, + ] + + const call1 = await MessageV2.toModelMessages(input, model, { remind: "a1" }) + const call2 = await MessageV2.toModelMessages(input, model, { remind: "a1" }) + expect(call1).toStrictEqual(call2) + }) + + test("does not mutate original message parts", async () => { + const part = { + ...basePart("u1", "p1"), + type: "text" as const, + text: "original", + } + const input: MessageV2.WithParts[] = [ + { + info: userInfo("u1"), + parts: [part] as MessageV2.Part[], + }, + ] + + await MessageV2.toModelMessages(input, model, { remind: "a0" }) + expect(part.text).toBe("original") + }) + + test("wraps multiple consecutive user messages after remind", async () => { + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo("a1", "u0"), + parts: [ + { + ...basePart("a1", "pa"), + type: "text", + text: "done", + }, + ] as MessageV2.Part[], + }, + { + info: userInfo("u1"), + parts: [ + { + ...basePart("u1", "p1"), + type: "text", + text: "first queued", + }, + ] as MessageV2.Part[], + }, + { + info: userInfo("u2"), + parts: [ + { + ...basePart("u2", "p2"), + type: "text", + text: "second queued", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "a1" }) + expect(result).toHaveLength(3) + const first = result[1].content as Array<{ type: string; text: string }> + const second = result[2].content as Array<{ type: string; text: string }> + expect(first[0].text).toContain("") + expect(first[0].text).toContain("first queued") + expect(second[0].text).toContain("") + expect(second[0].text).toContain("second queued") + }) + + test("does not wrap when remind equals message id exactly", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("msg_aaa"), + parts: [ + { + ...basePart("msg_aaa", "p1"), + type: "text", + text: "boundary msg", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model, { remind: "msg_aaa" }) + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "boundary msg" }], + }, + ]) + }) +}) + describe("session.message-v2.fromError", () => { test("serializes context_length_exceeded as ContextOverflowError", () => { const input = {