Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/opencode/src/kilocode/editor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? "+" : "-"
Expand All @@ -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}`)
}
Expand Down
62 changes: 36 additions & 26 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()

const state = Instance.state(
() => {
const data: Record<
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions packages/opencode/test/kilocode/editor-context.test.ts
Original file line number Diff line number Diff line change
@@ -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(/^<environment_details>\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([])
})
})
Loading