mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
test(cli): cover compaction media safety
This commit is contained in:
@@ -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?")]),
|
||||
|
||||
@@ -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 extends string, Input, Output>(_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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user