perf(vscode): slim transcript payloads

This commit is contained in:
marius-kilocode
2026-06-02 17:25:59 +02:00
parent 0e60d0fd7e
commit 623821ced5
5 changed files with 164 additions and 17 deletions
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Speed up Agent Manager switching for long sessions by lazily mounting collapsed historical tool details and sharing timeline hover infrastructure across activity bars.
Speed up Agent Manager switching for long sessions by lazily mounting collapsed historical tool details, sharing timeline hover infrastructure across activity bars, and omitting transcript metadata that the webview does not use.
+14 -8
View File
@@ -58,7 +58,7 @@ import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { renameSession } from "./kilo-provider/rename-session"
@@ -356,8 +356,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
// Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy
// sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts.
// Strip metadata unused by the webview to keep session switches fast.
// Logic in kilo-provider/slim-metadata.ts.
private slimInfo<T>(info: T): T {
if (!this.slimEditMetadata) return info
return slimInfo(info)
}
private slimPart<T>(part: T): T {
if (!this.slimEditMetadata) return part
return slimPart(part)
@@ -1466,7 +1471,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// no abort controller, so this guard prevents ghost entries.
if (!this.trackedSessionIds.has(sessionID)) return
const messages = page.items.map((m) => ({
...m.info,
...this.slimInfo(m.info),
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
@@ -1524,7 +1529,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
const messages = messagesData.map((m) => ({
...m.info,
...this.slimInfo(m.info),
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
@@ -3159,11 +3164,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.streams.push({ ...msg, part: this.slimPart(msg.part) })
return
}
if (msg.type === "indexingStatusLoaded") {
this.cachedIndexingStatusMessage = msg
const next = msg.type === "messageCreated" ? { ...msg, message: this.slimInfo(msg.message) } : msg
if (next.type === "indexingStatusLoaded") {
this.cachedIndexingStatusMessage = next
}
this.streams.flush(sessionID)
this.postMessage(msg)
this.postMessage(next)
}
/** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */
@@ -1,15 +1,18 @@
/**
* Pure data-transform helpers that strip heavy tool metadata from
* message parts before sending them to the webview via postMessage.
* Pure data-transform helpers that strip heavy message metadata before
* sending transcripts to the webview via postMessage.
*
* The webview communicates with the extension over VS Code's IPC bridge.
* Every message is JSON-serialised → deserialised on each side. Tool parts
* Every message is JSON-serialised → deserialised on each side. Tool parts
* from edit, apply_patch, multiedit and write often carry full file contents
* (before/after snapshots, patch text, written content). Sending those on
* every session switch makes serialisation the dominant bottleneck.
* (before/after snapshots, patch text, written content). User message summaries
* and reasoning parts can also carry patches and encrypted provider metadata
* that the webview does not use. Sending those on every session switch makes
* serialisation the dominant bottleneck.
*
* This module strips fields the webview never (or rarely) needs while keeping
* everything required to render collapsed tool-part headers and diagnostics.
* everything required to render transcript summaries, tool details and
* diagnostics.
*
* No vscode dependency — safe to unit-test in isolation.
*/
@@ -181,6 +184,24 @@ function slimBash(state: Record<string, unknown>): Record<string, unknown> {
// Public API
// ---------------------------------------------------------------------------
/** Strip patches used only by the explicit turn-diff fetch from message summaries. */
export function slimInfo<T>(info: T): T {
if (!info || typeof info !== "object") return info
const obj = info as Record<string, unknown>
const summary = obj.summary
if (!isObj(summary) || !Array.isArray(summary.diffs)) return info
if (!summary.diffs.some((diff) => isObj(diff) && "patch" in diff)) return info
const diffs = summary.diffs.map((diff) => {
if (!isObj(diff) || !("patch" in diff)) return diff
const next = { ...diff }
delete next.patch
return next
})
return { ...obj, summary: { ...summary, diffs } } as T
}
const slimmers: Record<string, (state: Record<string, unknown>) => Record<string, unknown>> = {
read: slimOutput,
list: slimOutput,
@@ -193,11 +214,24 @@ const slimmers: Record<string, (state: Record<string, unknown>) => Record<string
bash: slimBash,
}
/** Strip heavy metadata from a single tool part; pass-through for non-tool parts. */
/** Strip provider metadata that the webview never reads from reasoning parts. */
function slimReasoning<T>(part: T, obj: Record<string, unknown>): T {
const meta = obj.metadata
if (!isObj(meta)) return part
const openai = meta.openai
if (!isObj(openai) || !("reasoningEncryptedContent" in openai)) return part
const next = { ...openai }
delete next.reasoningEncryptedContent
return { ...obj, metadata: { ...meta, openai: next } } as T
}
/** Strip heavy metadata from a single transcript part. */
export function slimPart<T>(part: T): T {
if (!part || typeof part !== "object") return part
const obj = part as Record<string, unknown>
if (obj.type === "reasoning") return slimReasoning(part, obj)
if (obj.type !== "tool") return part
const tool = obj.tool
@@ -116,6 +116,7 @@ type ProviderInternals = {
sessionDirectories: Map<string, string>
trackedSessionIds: Set<string>
stopCurrentSessionProcesses: (next?: string) => void
handleEvent: (event: unknown) => void
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
}
@@ -302,6 +303,74 @@ describe("KiloProvider.handleDeleteSession / background processes", () => {
})
})
describe("KiloProvider.handleLoadMessages / slim payload", () => {
it("strips transcript-only metadata before posting messages to the webview", async () => {
const user = mkMessage("m1", "user", 1)
const assistant = mkMessage("m2", "assistant", 2)
const client = createClient({
messagesData: [
{
...user,
info: {
...user.info,
summary: { diffs: [{ file: "a.ts", patch: "full patch", additions: 2, deletions: 1 }] },
},
},
{
...assistant,
parts: [
{
type: "reasoning",
id: "r1",
text: "Considering options",
metadata: { openai: { reasoningEncryptedContent: "encrypted", itemId: "item-1" } },
},
],
},
],
})
const { provider, sent } = makeProvider(client)
await provider.loadMessages("s1")
const loaded = sent.find(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
) as
| {
messages: Array<{
summary?: { diffs?: Array<Record<string, unknown>> }
parts: Array<{ metadata?: { openai?: Record<string, unknown> } }>
}>
}
| undefined
expect(loaded?.messages[0]?.summary?.diffs?.[0]).toEqual({ file: "a.ts", additions: 2, deletions: 1 })
expect(loaded?.messages[1]?.parts[0]?.metadata?.openai).toEqual({ itemId: "item-1" })
})
it("strips summary patches from live message updates", () => {
const client = createClient()
const { internal, sent } = makeProvider(client)
internal.handleEvent({
type: "message.updated",
properties: {
info: {
id: "m1",
sessionID: "s1",
role: "user",
time: { created: 1 },
summary: { diffs: [{ file: "a.ts", patch: "full patch", additions: 2, deletions: 1 }] },
},
},
})
const created = sent.find(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messageCreated",
) as { message?: { summary?: { diffs?: Array<Record<string, unknown>> } } } | undefined
expect(created?.message?.summary?.diffs?.[0]).toEqual({ file: "a.ts", additions: 2, deletions: 1 })
})
})
describe("KiloProvider.loadMessages / sub-agent viewer full history", () => {
it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => {
// Regression: SubAgentViewerProvider used to call client.session.messages
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { slimPart } from "../../src/kilo-provider/slim-metadata"
import { slimInfo, slimPart } from "../../src/kilo-provider/slim-metadata"
// ---------------------------------------------------------------------------
// Helpers
@@ -35,6 +35,28 @@ const DIAG = [
// Tests
// ---------------------------------------------------------------------------
describe("slimInfo", () => {
it("drops summary patches while keeping visible diff fields", () => {
const info = {
role: "user",
summary: {
title: "Updated files",
diffs: [{ file: "a.ts", patch: BIG, additions: 3, deletions: 1, status: "modified" }],
},
}
const slim = slimInfo(info)
expect(slim.summary.title).toBe("Updated files")
expect(slim.summary.diffs).toEqual([{ file: "a.ts", additions: 3, deletions: 1, status: "modified" }])
expect(info.summary.diffs[0]?.patch).toBe(BIG)
})
it("passes through summaries without patches unchanged", () => {
const info = { role: "user", summary: { diffs: [{ file: "a.ts", additions: 1, deletions: 0 }] } }
expect(slimInfo(info)).toBe(info)
})
})
describe("slimPart", () => {
it("passes through non-tool parts unchanged", () => {
const text = { type: "text", id: "t1", content: "hello" }
@@ -46,6 +68,22 @@ describe("slimPart", () => {
expect(slimPart(p)).toBe(p)
})
it("drops encrypted OpenAI reasoning metadata while keeping other provider fields", () => {
const reasoning = {
type: "reasoning",
id: "r1",
text: "Considering options",
metadata: {
openai: { reasoningEncryptedContent: BIG, itemId: "item-1" },
anthropic: { signature: "sig-1" },
},
}
const slim = slimPart(reasoning)
expect(slim.metadata).toEqual({ openai: { itemId: "item-1" }, anthropic: { signature: "sig-1" } })
expect(reasoning.metadata.openai.reasoningEncryptedContent).toBe(BIG)
})
// -----------------------------------------------------------------------
// edit
// -----------------------------------------------------------------------