fix(cli): preserve editor context prompt prefix

This commit is contained in:
marius-kilocode
2026-08-24 17:40:29 +02:00
parent b2fa8d285d
commit 54bb7f3de0
6 changed files with 205 additions and 54 deletions
@@ -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.
@@ -27,10 +27,9 @@ export function staticEnvLines(ctx?: EditorContext): string[] {
* Build a per-message <environment_details> 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}`)
}
@@ -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<string, string>
}
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("<environment_details>"),
)
)
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)
}
}
+10 -9
View File
@@ -138,14 +138,10 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (
input: PromptInput,
) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
readonly command: (
input: CommandInput,
) => Effect.Effect<SessionV1.WithParts, Image.Error | Error>
readonly command: (input: CommandInput) => Effect.Effect<SessionV1.WithParts, Image.Error | Error>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
@@ -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<PromptInput["parts"][number], { type: "file" }>,
@@ -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<Schema.Schema.Type<typeof PromptInput>, "parts" | "editorContext"> & {
parts: PartInputUnion[]
editorContext?: MessageV2.EditorContext
@@ -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("<environment_details>"),
)
}
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("<environment_details>example</environment_details>", 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)
})
})
@@ -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")
})
})