diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index 3a087bc82af..b4b1d98b9d6 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -24,7 +24,7 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * user message so the model always has fresh context. * Always includes at least the current timestamp. */ -function timestamp(): string { +function datestamp(): string { const now = new Date() const offset = -now.getTimezoneOffset() const sign = offset >= 0 ? "+" : "-" @@ -33,11 +33,11 @@ function timestamp(): string { .padStart(2, "0") const m = (Math.abs(offset) % 60).toString().padStart(2, "0") const pad = (n: number) => n.toString().padStart(2, "0") - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${h}:${m}` + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}${sign}${h}:${m}` } export function environmentDetails(ctx?: EditorContext): string { - const lines: string[] = [`Current time: ${timestamp()}`] + const lines: string[] = [`Current date: ${datestamp()}`] if (ctx?.activeFile) { lines.push(`Active file: ${ctx.activeFile}`) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3345aca761e..161d035945d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -78,6 +78,12 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) + // kilocode_change — persist environment details across loop() invocations so that + // older user messages keep their envBlock across turns, preserving the byte-identical + // conversation prefix for Anthropic prompt caching. + // Key: "sessionID:messageID" → rendered envBlock string. + const envCache = new Map() + const state = Instance.state( () => { const data: Record< @@ -289,6 +295,10 @@ export namespace SessionPrompt { } match.abort.abort() delete s[sessionID] + // kilocode_change — clean up envCache entries for this session + for (const k of envCache.keys()) { + if (k.startsWith(sessionID + ":")) envCache.delete(k) + } SessionStatus.set(sessionID, { type: "idle" }) return } @@ -324,12 +334,6 @@ export namespace SessionPrompt { // on the user message and will be retrieved from lastUser below let structuredOutput: unknown | undefined - // kilocode_change — cache environment details per turn so the last user - // message stays byte-identical across tool-loop steps (prompt caching). - // Keyed by user message ID so it recomputes when a new user message arrives. - let envBlock: string | undefined - let envUser: string | undefined - let step = 0 const session = await Session.get(sessionID) while (true) { @@ -701,27 +705,33 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - // kilocode_change start — ephemerally inject dynamic editor context into last user message - if (envUser !== lastUser.id) { - envBlock = environmentDetails(lastUser.editorContext) - envUser = lastUser.id + // kilocode_change start — ephemerally inject dynamic editor context into user messages. + // Each user message's envBlock is computed once (when it becomes the last user message) + // and preserved across loop() calls via the module-level envCache so the conversation + // prefix stays byte-identical for Anthropic prompt caching. Previously, only the *last* + // user message received the envBlock, causing earlier user messages to lose theirs on + // new turns and invalidating the cached prefix at that position. + const key = `${sessionID}:${lastUser.id}` + if (!envCache.has(key)) { + envCache.set(key, environmentDetails(lastUser.editorContext)) } - if (envBlock) { - const idx = msgs.findLastIndex((m) => m.info.role === "user") - if (idx !== -1) - msgs[idx] = { - ...msgs[idx], - parts: [ - ...msgs[idx].parts, - { - id: Identifier.ascending("part"), - sessionID, - messageID: msgs[idx].info.id, - type: "text", - text: envBlock, - } satisfies MessageV2.TextPart, - ], - } + for (let i = 0; i < msgs.length; i++) { + if (msgs[i].info.role !== "user") continue + const env = envCache.get(`${sessionID}:${msgs[i].info.id}`) + if (!env) continue + msgs[i] = { + ...msgs[i], + parts: [ + ...msgs[i].parts, + { + id: Identifier.ascending("part"), + sessionID, + messageID: msgs[i].info.id, + type: "text", + text: env, + } satisfies MessageV2.TextPart, + ], + } } // kilocode_change end diff --git a/packages/opencode/test/kilocode/editor-context.test.ts b/packages/opencode/test/kilocode/editor-context.test.ts new file mode 100644 index 00000000000..f2b3da1563c --- /dev/null +++ b/packages/opencode/test/kilocode/editor-context.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { environmentDetails, staticEnvLines } from "../../src/kilocode/editor-context" + +describe("environmentDetails", () => { + test("contains date-only timestamp without hours/minutes/seconds", () => { + const result = environmentDetails() + // Must contain a date line + expect(result).toContain("Current date:") + // Must NOT contain time-of-day (hours:minutes:seconds pattern like T13:32:40) + expect(result).not.toMatch(/Current (?:date|time): \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + // Must NOT use "Current time:" label at all + expect(result).not.toContain("Current time:") + }) + + test("date matches YYYY-MM-DD+HH:MM format", () => { + const result = environmentDetails() + expect(result).toMatch(/Current date: \d{4}-\d{2}-\d{2}[+-]\d{2}:\d{2}/) + }) + + test("is stable across consecutive calls on the same day", () => { + const a = environmentDetails() + const b = environmentDetails() + expect(a).toBe(b) + }) + + test("wraps in environment_details tags", () => { + const result = environmentDetails() + expect(result).toMatch(/^\n/) + expect(result).toMatch(/<\/environment_details>$/) + }) + + test("includes active file when provided", () => { + const result = environmentDetails({ activeFile: "src/index.ts" }) + expect(result).toContain("Active file: src/index.ts") + }) + + test("includes visible files when provided", () => { + const result = environmentDetails({ visibleFiles: ["a.ts", "b.ts"] }) + expect(result).toContain("Visible files:") + expect(result).toContain(" a.ts") + expect(result).toContain(" b.ts") + }) + + test("includes open tabs when provided", () => { + const result = environmentDetails({ openTabs: ["x.ts", "y.ts"] }) + expect(result).toContain("Open tabs:") + expect(result).toContain(" x.ts") + expect(result).toContain(" y.ts") + }) + + test("omits optional sections when not provided", () => { + const result = environmentDetails() + expect(result).not.toContain("Active file:") + expect(result).not.toContain("Visible files:") + expect(result).not.toContain("Open tabs:") + }) +}) + +describe("staticEnvLines", () => { + test("includes shell when provided", () => { + const result = staticEnvLines({ shell: "/bin/bash" }) + expect(result).toEqual([" Default shell: /bin/bash"]) + }) + + test("returns empty array when no context", () => { + expect(staticEnvLines()).toEqual([]) + expect(staticEnvLines({})).toEqual([]) + }) +})