Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quick-subagent-transcripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Open subagent transcripts faster while preserving live reasoning and paginating long histories.
15 changes: 9 additions & 6 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

public loadMessages(sessionID: string): Promise<void> {
// Sub-agent viewer: full transcript (no "load earlier" UI, no pagination).
return this.handleLoadMessages(sessionID, { limit: 0 })
// Sub-agent viewers share the normal paginated transcript and preserve
// live deltas that arrive while the initial page is loading.
return this.handleLoadMessages(sessionID, { preserveStream: true })
}

/**
Expand Down Expand Up @@ -1482,7 +1483,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

private async handleLoadMessages(
sessionID: string,
options: { mode?: MessageLoadMode; before?: string; limit?: number } = {},
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
): Promise<void> {
const mode = options.mode ?? "replace"
if (mode === "replace" || mode === "focus") {
Expand Down Expand Up @@ -1531,9 +1532,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
for (const message of messages) {
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
}
// Authoritative snapshot: drop queued deltas. Prepend is older history
// and must not clobber live deltas.
if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID)
// Authoritative snapshots normally supersede buffered deltas. A newly
// opened sub-agent viewer has no earlier renderer state, so its buffered
// updates arrived during this fetch and must follow the snapshot.
if ((mode === "replace" || mode === "reconcile") && !options.preserveStream) this.streams.drop(sessionID)
if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now())
this.postMessage({
type: "messagesLoaded",
Expand All @@ -1544,6 +1546,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
hasMore: Boolean(page.cursor),
since,
})
if (options.preserveStream) this.streams.flush(sessionID)
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
this.recoverPendingPrompts()
} catch (error) {
Expand Down
32 changes: 15 additions & 17 deletions packages/kilo-vscode/src/SubAgentViewerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,30 @@ export class SubAgentViewerProvider implements vscode.Disposable {
}

const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context)
// Start accepting this session's SSE events as soon as the panel subscribes.
// Reasoning deltas are not persisted until the reasoning part finishes.
provider.trackSession(sessionID)
provider.resolveWebviewPanel(panel)

// Once the webview is ready, fetch the session and display it in read-only mode.
const readyDisposable = panel.webview.onDidReceiveMessage(async (msg) => {
// Navigate immediately when the webview is ready, then load metadata and
// the same paginated, row-virtualized transcript used by normal sessions.
const readyDisposable = panel.webview.onDidReceiveMessage((msg) => {
if (msg.type !== "webviewReady") return
readyDisposable.dispose()

// Small delay to let KiloProvider's own webviewReady handler finish first
await new Promise((resolve) => setTimeout(resolve, 50))
provider.postMessage({ type: "viewSubAgentSession", sessionID })
void provider.loadMessages(sessionID)

try {
const client = this.connectionService.getClient()
const { data: session } = await client.session.get({ sessionID }, { throwOnError: true })

// Register the session on the provider — this adds it to
// trackedSessionIds for live SSE updates and sends
// sessionCreated to the webview.
provider.registerSession(session)

// Fetch the newest page before navigating so the tab opens on the latest turn.
await provider.loadMessages(sessionID)

// Navigate to the sub-agent viewer
provider.postMessage({ type: "viewSubAgentSession", sessionID })
void client.session
.get({ sessionID }, { throwOnError: true })
.then(({ data: session }) => provider.registerSession(session))
.catch((err: unknown) => {
console.error("[Kilo New] SubAgentViewerProvider: Failed to load session metadata:", err)
})
} catch (err) {
console.error("[Kilo New] SubAgentViewerProvider: Failed to load session:", err)
console.error("[Kilo New] SubAgentViewerProvider: Failed to load session metadata:", err)
}
})

Expand Down
3 changes: 1 addition & 2 deletions packages/kilo-vscode/src/kilo-provider/message-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export async function fetchMessagePage(
signal?: AbortSignal
},
) {
// limit: 0 is the server contract for "return every message" — used by
// the sub-agent viewer, which has no "load earlier" UI.
// limit: 0 is the server contract for "return every message".
const full = input.limit === 0
const read = async (before?: string) => {
const result = await retry(() =>
Expand Down
70 changes: 53 additions & 17 deletions packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from "bun:test"
import type { PartUpdate } from "../../src/shared/stream-messages"

// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")
Expand Down Expand Up @@ -115,6 +116,7 @@ type ProviderInternals = {
contextSessionID: string | undefined
sessionDirectories: Map<string, string>
trackedSessionIds: Set<string>
streams: { push: (msg: PartUpdate) => void }
stopCurrentSessionProcesses: (next?: string) => void
handleEvent: (event: unknown) => void
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
Expand Down Expand Up @@ -371,30 +373,64 @@ describe("KiloProvider.handleLoadMessages / slim payload", () => {
})
})

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
// with no limit, loading every turn. After switching to provider.loadMessages
// it inherited the 80-message page cap and sub-agents with more than 80
// turns would open truncated with no visible indicator. loadMessages() is
// the sub-agent viewer's single entry point — it must request the full
// transcript.
const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i))
const client = createClient({ messagesData: big })
describe("KiloProvider.loadMessages / sub-agent viewer", () => {
it("uses the same paginated initial load as normal sessions", async () => {
const page = Array.from({ length: 80 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i))
const client = createClient({ messagesData: page })
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: unknown[] } | undefined
expect(loaded).toBeDefined()
expect(loaded!.messages).toHaveLength(200)
) as { messages: unknown[]; hasMore: boolean } | undefined
expect(loaded?.messages).toHaveLength(80)
expect(loaded?.hasMore).toBe(true)
expect(client.calls).toEqual([{ before: undefined, limit: 80 }])
})

it("delivers reasoning updates received during the initial snapshot after messagesLoaded", async () => {
const pending = defer<{ data: unknown[]; response: { headers: Headers } }>()
const client = createClient({ messagesDeferred: pending })
const { provider, internal, sent } = makeProvider(client)
const load = provider.loadMessages("s1")

internal.streams.push({
type: "partUpdated",
sessionID: "s1",
messageID: "m2",
part: {
id: "r1",
sessionID: "s1",
messageID: "m2",
type: "reasoning",
text: "Complete reasoning",
},
})
pending.resolve(
mkResult([
mkMessage("m1", "user", 1),
{
...mkMessage("m2", "assistant", 2),
parts: [
{
id: "r1",
sessionID: "s1",
messageID: "m2",
type: "reasoning",
text: "",
},
],
},
]),
)
await load

// Server contract: limit: 0 (or undefined) returns everything.
expect(client.calls).toHaveLength(1)
const limit = client.calls[0]?.limit
expect(limit === undefined || limit === 0).toBe(true)
const types = sent.map((msg) => (typeof msg === "object" && msg ? (msg as { type?: string }).type : undefined))
const snapshot = types.indexOf("messagesLoaded")
const update = types.findIndex((type) => type === "partUpdated" || type === "partsUpdated")
expect(snapshot).toBeGreaterThanOrEqual(0)
expect(update).toBeGreaterThan(snapshot)
})
})

Expand Down
Loading