mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix(cli): preserve editor context prompt prefix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user