diff --git a/.changeset/stable-editor-context-prompt-prefix.md b/.changeset/stable-editor-context-prompt-prefix.md new file mode 100644 index 0000000000..322da1e6a9 --- /dev/null +++ b/.changeset/stable-editor-context-prompt-prefix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop moving editor context between user messages so providers with prefix caching, including local models, can reuse the conversation across turns. diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index ef7c077580..605fba20f8 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -27,10 +27,9 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * Build a per-message block from editor context. * These change frequently (user switches files/tabs) and belong in the * user message so the model always has fresh context. - * Always includes at least the current timestamp. + * Always includes at least the supplied message timestamp. */ -function timestamp(): string { - const now = new Date() +function timestamp(now: Date): string { const offset = -now.getTimezoneOffset() const sign = offset >= 0 ? "+" : "-" const h = Math.floor(Math.abs(offset) / 60) @@ -41,8 +40,8 @@ function timestamp(): string { return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${h}:${m}` } -export function environmentDetails(ctx?: EditorContext): string { - const lines: string[] = [`Current time: ${timestamp()}`] +export function environmentDetails(ctx?: EditorContext, now = new Date()): string { + const lines: string[] = [`Message time: ${timestamp(now)}`] if (ctx?.directory) { lines.push(`Working directory: ${ctx.directory}`) } diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 4b997d2a5f..c7337ac779 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -270,7 +270,10 @@ export namespace KiloSessionPrompt { const taggedSession = PermissionProvenance.tagSession(session.permission ?? []) const ruleset = Permission.merge( taggedAgent, - guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }), + guardPermissions({ + agent: { name: agent.name, permission: taggedAgent }, + session: { permission: taggedSession }, + }), ) const outcome = yield* input.permission.ask({ ...input.request, ruleset, hardRuleset: hardPermissions({ agent }) }) if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval @@ -278,13 +281,9 @@ export namespace KiloSessionPrompt { // kilocode_change end }) - /** - * Mutable cache for environment details, keyed by user message ID - * so it recomputes when a new user message arrives. - */ + /** Mutable per-turn cache for deterministic environment detail blocks. */ export interface EnvCache { - block?: string - user?: string + blocks?: Map } export function memoryToolEnabled(input: { ctx: MemoryPaths.Ctx }) { @@ -356,9 +355,10 @@ export namespace KiloSessionPrompt { } /** - * Ephemerally injects dynamic editor context (visible files, open tabs, etc.) - * into the last user message. Caches the result per user message ID so repeated - * loop iterations produce byte-identical messages (prompt caching). + * Reconstructs dynamic editor context on every user message without + * persisting synthetic prompt scaffolding. Using each message's creation + * time keeps historical blocks byte-identical, so later turns only append + * instead of moving the block and discarding the provider prompt cache. */ export function injectEditorContext(input: { msgs: MessageV2.WithParts[] @@ -366,36 +366,41 @@ export namespace KiloSessionPrompt { sessionID: SessionID cache: EnvCache }) { - if (input.cache.user !== input.lastUser.id) { - const ctx = (() => { - try { - return Instance.current - } catch { - return undefined - } - })() - input.cache.block = environmentDetails({ - ...input.lastUser.editorContext, - ...(ctx ? { directory: ctx.directory, worktree: ctx.worktree } : {}), - }) - input.cache.user = input.lastUser.id - } - if (!input.cache.block) return - const idx = input.msgs.findLastIndex((m) => m.info.role === "user") - if (idx === -1) return - input.msgs[idx] = { - ...input.msgs[idx], - parts: [ - ...input.msgs[idx].parts, - { - id: PartID.make(Identifier.ascending("part")), - sessionID: input.sessionID, - messageID: input.msgs[idx].info.id, - type: "text", - text: input.cache.block, - synthetic: true, - } satisfies MessageV2.TextPart, - ], + const current = (() => { + try { + const ctx = Instance.current + return { directory: ctx.directory, worktree: ctx.worktree } + } catch { + return undefined + } + })() + input.cache.blocks ??= new Map() + for (const msg of input.msgs) { + if (msg.info.role !== "user") continue + if ( + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.startsWith(""), + ) + ) + continue + const block = + input.cache.blocks.get(msg.info.id) ?? + environmentDetails( + { + ...current, + ...msg.info.editorContext, + }, + new Date(msg.info.time.created), + ) + input.cache.blocks.set(msg.info.id, block) + msg.parts.push({ + id: PartID.make(Identifier.ascending("part")), + sessionID: input.sessionID, + messageID: msg.info.id, + type: "text", + text: block, + synthetic: true, + } satisfies MessageV2.TextPart) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 092666a3ef..2a9239b0b5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -138,14 +138,10 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: ( - input: PromptInput, - ) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: ( - input: CommandInput, - ) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -837,6 +833,8 @@ export const layer = Layer.effect( (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) // kilocode_change end + // kilocode_change start - freeze the route used by dynamic editor context and reuse it for references + const ctx = yield* InstanceState.context const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), role: "user", @@ -851,7 +849,7 @@ export const layer = Layer.effect( }, system: input.system, format: input.format, - editorContext: input.editorContext, // kilocode_change + editorContext: { ...input.editorContext, directory: ctx.directory, worktree: ctx.worktree }, } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -881,12 +879,12 @@ export const layer = Layer.effect( id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const ctx = yield* InstanceState.context // kilocode_change - resolve V1 reference roots for attachment authorization const references = KiloConfiguredReference.resolveAll({ references: (yield* config.get()).reference ?? {}, directory: ctx.directory, worktree: ctx.worktree, }).filter((item) => item.kind !== "invalid") + // kilocode_change end const referenceContextFromFilePart = Effect.fnUntraced(function* ( part: Extract, @@ -2518,7 +2516,10 @@ export const PromptInput = Schema.Struct({ // `parts` type from the exported Schema input types so callers see a proper // tagged union. type PartInputUnion = - MessageV2.TextPartInput | MessageV2.FilePartInput | MessageV2.AgentPartInput | MessageV2.SubtaskPartInput + | MessageV2.TextPartInput + | MessageV2.FilePartInput + | MessageV2.AgentPartInput + | MessageV2.SubtaskPartInput export type PromptInput = Omit, "parts" | "editorContext"> & { parts: PartInputUnion[] editorContext?: MessageV2.EditorContext diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts new file mode 100644 index 0000000000..f5bd457c80 --- /dev/null +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { Provider } from "../../src/provider/provider" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, SessionID } from "../../src/session/schema" + +const sessionID = SessionID.make("ses_test") +const model = { + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-4"), +} +const mdl: Provider.Model = { + id: model.modelID, + providerID: model.providerID, + api: { id: model.modelID, url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100_000, input: 100_000, output: 10_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function user(text: string, created: number, activeFile?: string, route = "/repo/original") { + const id = MessageID.ascending() + return { + info: { + id, + role: "user" as const, + sessionID, + time: { created }, + agent: "code", + model, + editorContext: { directory: route, worktree: route, ...(activeFile ? { activeFile } : {}) }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function assistant(parentID: MessageID, text: string) { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant" as const, + sessionID, + time: { created: Date.now() }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function inject(msgs: MessageV2.WithParts[], cache: KiloSessionPrompt.EnvCache = {}) { + const last = msgs.findLast((msg) => msg.info.role === "user") + if (!last || last.info.role !== "user") throw new Error("missing user message") + KiloSessionPrompt.injectEditorContext({ msgs, lastUser: last.info, sessionID, cache }) +} + +function blocks(msg: MessageV2.WithParts) { + return msg.parts.filter( + (part): part is MessageV2.TextPart => + part.type === "text" && !!part.synthetic && part.text.startsWith(""), + ) +} + +async function prompt(msgs: MessageV2.WithParts[]) { + return JSON.stringify(await MessageV2.toModelMessages(msgs, mdl)) +} + +describe("injectEditorContext", () => { + test("keeps the previous model prompt byte-identical across turns without persisting blocks", async () => { + const stored1 = user("2 + 2", Date.parse("2026-08-24T12:00:00Z"), "src/one.ts") + const turn1 = [structuredClone(stored1)] + inject(turn1) + const first = await prompt(turn1) + + expect(blocks(turn1[0])).toHaveLength(1) + expect(blocks(stored1)).toHaveLength(0) + + const stored2 = user("3 + 3", Date.parse("2026-08-24T12:01:00Z"), "src/two.ts", "/repo/next") + const turn2 = [structuredClone(stored1), assistant(stored1.info.id, "4"), structuredClone(stored2)] + inject(turn2) + + expect((await prompt(turn2)).startsWith(first.slice(0, -1))).toBe(true) + expect(blocks(turn2[0])).toHaveLength(1) + expect(blocks(turn2[2])).toHaveLength(1) + expect(blocks(turn2[0])[0].text).toContain("Active file: src/one.ts") + expect(blocks(turn2[2])[0].text).toContain("Active file: src/two.ts") + expect(blocks(turn2[0])[0].text).toContain("Message time: 2026-08-24T") + expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/original") + expect(blocks(turn2[2])[0].text).toContain("Working directory: /repo/next") + }) + + test("is byte-identical across repeated loop iterations", async () => { + const stored = user("list files", Date.parse("2026-08-24T12:00:00Z")) + const cache: KiloSessionPrompt.EnvCache = {} + const first = [structuredClone(stored)] + const second = [structuredClone(stored)] + + inject(first, cache) + inject(second, cache) + + expect(await prompt(second)).toBe(await prompt(first)) + expect(blocks(second[0])).toHaveLength(1) + }) + + test("does not mistake user-authored markup for an injected block", () => { + const stored = user("example", Date.parse("2026-08-24T12:00:00Z")) + const msgs = [stored] + inject(msgs) + + expect(blocks(stored)).toHaveLength(1) + expect(stored.parts.filter((part) => part.type === "text")).toHaveLength(2) + }) +}) diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index 10cea1161d..b82b2265fe 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -141,4 +141,10 @@ describe("environmentDetails", () => { expect(result).toContain("Workspace root folder: /repo/.kilo/worktrees/feature") expect(result).toContain("Active file: src/app.ts") }) + + test("formats the supplied message time", () => { + const result = environmentDetails({}, new Date(2026, 7, 24, 12, 34, 56)) + + expect(result).toContain("Message time: 2026-08-24T12:34:56") + }) })