diff --git a/packages/opencode/test/kilocode/session-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-compaction-safety.test.ts index 2abdc94535..ea76bb8800 100644 --- a/packages/opencode/test/kilocode/session-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-safety.test.ts @@ -87,27 +87,44 @@ function filePart( function toolPart( messageID: string, - status: "completed" | "error", + status: "completed" | "error" | "pending" | "running", attachments?: MessageV2.FilePart[], partID = "p_tool_" + messageID, ): MessageV2.ToolPart { - const state = - status === "completed" - ? { - status: "completed" as const, - input: {}, - output: "done", - title: "tool", - metadata: {}, - time: { start: 0, end: 1 }, - attachments, - } - : { - status: "error" as const, - input: {}, - error: "boom", - time: { start: 0, end: 1 }, - } + const state = (() => { + if (status === "completed") { + return { + status: "completed" as const, + input: {}, + output: "done", + title: "tool", + metadata: {}, + time: { start: 0, end: 1 }, + attachments, + } + } + if (status === "error") { + return { + status: "error" as const, + input: {}, + error: "boom", + time: { start: 0, end: 1 }, + } + } + if (status === "running") { + return { + status: "running" as const, + input: {}, + title: "tool", + time: { start: 0 }, + } + } + return { + status: "pending" as const, + input: {}, + raw: "{}", + } + })() return { id: PartID.make(partID), sessionID, @@ -182,7 +199,10 @@ describe("KiloSessionPrompt.trimBeforeLastSummary", () => { }), user("msg_next", [textPart("msg_next", "next prompt")]), ] - const result = KiloSessionPrompt.trimBeforeLastSummary(msgs) + const filtered = MessageV2.filterCompacted([...msgs].reverse()) + expect(filtered.map((m) => m.info.id)).toEqual(msgs.map((m) => m.info.id)) + + const result = KiloSessionPrompt.trimBeforeLastSummary(filtered) expect(result.map((m) => m.info.id)).toEqual([ MessageID.make("msg_status"), MessageID.make("msg_summary"), @@ -271,6 +291,16 @@ describe("KiloSessionPrompt.stripHistoricalMedia", () => { expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("[Attached image/png: file]") }) + test("replaces historical PDF file part with placeholder text", () => { + const msgs = [ + user("msg_hist", [filePart("msg_hist", "application/pdf", "brief.pdf")]), + user("msg_last", [textPart("msg_last", "follow-up")]), + ] + const result = KiloSessionPrompt.stripHistoricalMedia(msgs) + expect(result[0].parts[0].type).toBe("text") + expect((result[0].parts[0] as MessageV2.TextPart).text).toBe("[Attached application/pdf: brief.pdf]") + }) + test("does NOT touch media in the last user message", () => { const lastImage = filePart("msg_last", "image/png", "last.png") const msgs = [user("msg_hist", [textPart("msg_hist", "older")]), user("msg_last", [lastImage])] @@ -307,6 +337,23 @@ describe("KiloSessionPrompt.stripHistoricalMedia", () => { expect(resultTool.state.title).toBe("tool") }) + test("does NOT touch non-completed tool parts", () => { + const err = toolPart("msg_error", "error") + const pending = toolPart("msg_pending", "pending") + const running = toolPart("msg_running", "running") + const msgs = [ + user("msg_u1", [textPart("msg_u1", "question")]), + assistant("msg_error", "msg_u1", [err], { finish: "end_turn" }), + assistant("msg_pending", "msg_u1", [pending], { finish: "end_turn" }), + assistant("msg_running", "msg_u1", [running], { finish: "end_turn" }), + user("msg_last", [textPart("msg_last", "follow-up")]), + ] + const result = KiloSessionPrompt.stripHistoricalMedia(msgs) + expect(result[1].parts[0]).toBe(err) + expect(result[2].parts[0]).toBe(pending) + expect(result[3].parts[0]).toBe(running) + }) + test("no-op when there are no user messages", () => { const msgs = [assistant("msg_a1", "msg_ghost", [], { finish: "end_turn" })] const result = KiloSessionPrompt.stripHistoricalMedia(msgs) @@ -381,6 +428,21 @@ describe("KiloSessionPrompt.maybeStripHistoricalMedia", () => { expect(result).toBe(msgs) }) + test("returns input unchanged when summaries are errored or unfinished", () => { + const image = filePart("msg_hist", "image/png", "hist.png") + const msgs = [ + user("msg_u1", [textPart("msg_u1", "status?")]), + assistant("msg_error", "msg_u1", [], { summary: true, finish: "end_turn", error: apiError }), + user("msg_u2", [textPart("msg_u2", "again")]), + assistant("msg_unfinished", "msg_u2", [], { summary: true }), + user("msg_hist", [image]), + user("msg_last", [textPart("msg_last", "follow-up")]), + ] + const result = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) + expect(result).toBe(msgs) + expect(result[4].parts[0]).toBe(image) + }) + test("strips history when a completed summary exists", () => { const msgs = [ user("msg_status", [textPart("msg_status", "status?")]), diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts new file mode 100644 index 0000000000..19e5cc665c --- /dev/null +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -0,0 +1,346 @@ +// Regressions for SessionPrompt.runLoop compaction-history safety. +// Ensures Kilo's post-filterCompacted trim and post-summary media strip are +// applied before messages are serialized for the provider request. + +import { NodeFileSystem } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Config } from "../../src/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Env } from "../../src/env" +import { Ripgrep } from "../../src/file/ripgrep" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Format } from "../../src/format" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider as ProviderSvc } from "../../src/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Question } from "../../src/question" +import { Session } from "../../src/session" +import { SessionCompaction } from "../../src/session/compaction" +import { Instruction } from "../../src/session/instruction" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionRunState } from "../../src/session/run-state" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { SystemPrompt } from "../../src/session/system" +import { SessionSummary } from "../../src/session/summary" +import { Todo } from "../../src/session/todo" +import { Skill } from "../../src/skill" +import { Snapshot } from "../../src/snapshot" +import { ToolRegistry, Truncate } from "../../src/tool" +import { Log } from "../../src/util" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" + +Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const plugin = Layer.mock(Plugin.Service)({ + trigger: (_name: Name, _input: Input, output: Output) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, +}) + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt safety tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt safety tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt safety tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) + +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + Env.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + plugin, + Config.defaultLayer, + ProviderSvc.defaultLayer, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), + Layer.provideMerge(run), + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provideMerge(deps), + ), + ).pipe(Layer.provide(summary)) +} + +const it = testEffect(makeHttp()) + +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: true, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} + +function providerCfg(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { + ...cfg.provider.test.options, + baseURL: url, + }, + }, + }, + } +} + +const user = Effect.fn("prompt-safety.user")(function* (sessionID: SessionID, text: string) { + const sessions = yield* Session.Service + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "code", + model: ref, + time: { created: Date.now() }, + tools: {}, + } satisfies MessageV2.User) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + } satisfies MessageV2.TextPart) + return msg +}) + +const assistant = Effect.fn("prompt-safety.assistant")(function* ( + sessionID: SessionID, + parentID: MessageID, + input?: { text?: string; summary?: boolean }, +) { + const sessions = yield* Session.Service + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID, + sessionID, + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + finish: "end_turn", + summary: input?.summary, + } satisfies MessageV2.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text: input?.text ?? "done", + } satisfies MessageV2.TextPart) + return msg +}) + +const file = Effect.fn("prompt-safety.file")(function* ( + sessionID: SessionID, + messageID: MessageID, + input: { mime: string; name: string; body: string }, +) { + const sessions = yield* Session.Service + return yield* sessions.updatePart({ + id: PartID.ascending(), + messageID, + sessionID, + type: "file", + mime: input.mime, + filename: input.name, + url: `data:${input.mime};base64,${input.body}`, + } satisfies MessageV2.FilePart) +}) + +describe("SessionPrompt compaction safety", () => { + it.live("trims plain-text summary history before provider request", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Prompt safety", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const early = yield* user(chat.id, "old prompt with image") + yield* file(chat.id, early.id, { mime: "image/png", name: "old.png", body: "OLDPAYLOAD" }) + yield* assistant(chat.id, early.id, { text: "old answer" }) + const status = yield* user(chat.id, "status?") + yield* assistant(chat.id, status.id, { text: "summary body", summary: true }) + yield* user(chat.id, "new prompt") + yield* llm.text("final answer") + + yield* prompt.loop({ sessionID: chat.id }) + + const inputs = yield* llm.inputs + const body = JSON.stringify(inputs.at(-1)?.messages) + expect(body).toContain("status?") + expect(body).toContain("summary body") + expect(body).toContain("new prompt") + expect(body).not.toContain("old prompt with image") + expect(body).not.toContain("OLDPAYLOAD") + }), + { git: true, config: providerCfg }, + ), + ) + + it.live("strips historical media before provider request", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Prompt media safety", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const status = yield* user(chat.id, "status?") + yield* assistant(chat.id, status.id, { text: "summary body", summary: true }) + const hist = yield* user(chat.id, "historical media") + yield* file(chat.id, hist.id, { mime: "image/png", name: "hist.png", body: "HISTIMAGE" }) + yield* file(chat.id, hist.id, { mime: "application/pdf", name: "hist.pdf", body: "HISTPDF" }) + yield* user(chat.id, "current prompt") + yield* llm.text("final answer") + + yield* prompt.loop({ sessionID: chat.id }) + + const inputs = yield* llm.inputs + const body = JSON.stringify(inputs.at(-1)?.messages) + expect(body).toContain("[Attached image/png: hist.png]") + expect(body).toContain("[Attached application/pdf: hist.pdf]") + expect(body).toContain("current prompt") + expect(body).not.toContain("HISTIMAGE") + expect(body).not.toContain("HISTPDF") + }), + { git: true, config: providerCfg }, + ), + ) +})