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
6 changes: 6 additions & 0 deletions .changeset/fresh-compaction-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---

Keep post-compaction tool calls and follow-up messages ordered after the compaction summary in the CLI and VS Code transcript.
84 changes: 84 additions & 0 deletions packages/kilo-vscode/tests/unit/session-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const base = {

const user = (id: string): Message => ({ ...base, id, role: "user" })

const compact = (id: string): Message => ({
...user(id),
parts: [{ id: `part_${id}`, sessionID: base.sessionID, messageID: id, type: "compaction", auto: false }],
})

const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): Message => ({
...base,
id,
Expand Down Expand Up @@ -316,6 +321,42 @@ describe("messageTurns", () => {
])
})

it("keeps resumed replies after a persisted compaction turn", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
]

expect(
messageTurns(messages).map((turn) => ({
user: turn.user.id,
assistant: turn.assistant.map((msg) => msg.id),
})),
).toEqual([
{ user: "message_1", assistant: ["message_2"] },
{ user: "message_3", assistant: ["message_4", "message_5"] },
])
})

it("detects persisted compaction parts through the lazy lookup", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
user("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
]

expect(
visibleMessages(messages, undefined, (msg) => (msg.id === "message_3" ? compact(msg.id).parts : msg.parts)).map(
(msg) => msg.id,
),
).toEqual(["message_1", "message_2", "message_3", "message_4", "message_5"])
})

it("surfaces leading assistant output as partial turns grouped by parent", () => {
const messages = [
assistant("message_2", "message_1"),
Expand Down Expand Up @@ -437,6 +478,49 @@ describe("activeUserMessageID", () => {
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1")
})

it("maps resumed post-compaction tool calls to the compaction turn", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "tool-calls" }),
user("message_6"),
]

expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3")
expectLayout(messages, { type: "busy" }, { virtual: ["message_1"], direct: ["message_3"], queued: ["message_6"] })
})

it("uses lazy compaction parts when mapping resumed tool calls", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
user("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "tool-calls" }),
]

expect(
activeUserMessageID(messages, { type: "busy" }, (msg) =>
msg.id === "message_3" ? compact(msg.id).parts : msg.parts,
),
).toBe("message_3")
})

it("advances beyond a completed post-compaction reply", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "stop" }),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
user("message_6"),
]

expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_6")
})

it("ignores completed tool-call assistants after the session becomes idle", () => {
const messages = [
user("message_1"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,21 @@ export const MessageList: Component<MessageListProps> = (props) => {

const boundary = () => session.revert()?.messageID
const turns = createMemo((prev: MessageTurn[] | undefined) =>
stableMessageTurns(messageTurns(session.messages(), boundary()), prev),
stableMessageTurns(
messageTurns(session.messages(), boundary(), (msg) => session.getParts(msg.id)),
prev,
),
)
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()

const recent = createMemo(() => recentSessions(session.sessions()))

const activeUserID = createMemo(() => getActiveUserMessageID(session.messages(), session.statusInfo()))
const queuedIDs = createMemo(() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo())))
const activeUserID = createMemo(() =>
getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)),
)
const queuedIDs = createMemo(
() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))),
)
const [held, setHeld] = createSignal<{ sid: string; ids: Set<string> }>()
createEffect(() => {
const id = activeUserID()
Expand Down
84 changes: 61 additions & 23 deletions packages/kilo-vscode/webview-ui/src/context/session-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,52 @@ function partials(messages: Message[]): MessageTurn[] {
.map(partial)
}

export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] {
function isCompact(msg: Message, parts?: (msg: Message) => Message["parts"]) {
return msg.role === "user" && (parts?.(msg) ?? msg.parts)?.some((part) => part.type === "compaction")
}

function target(messages: Message[], index: number, id: string, parts?: (msg: Message) => Message["parts"]) {
const parent = messages.findIndex((msg) => msg.id === id)
for (let i = index - 1; i > parent; i -= 1) {
const msg = messages[i]
if (msg && isCompact(msg, parts)) return msg.id
}
return id
}

export function messageTurns(
messages: Message[],
boundary?: string,
parts?: (msg: Message) => Message["parts"],
): MessageTurn[] {
const result: MessageTurn[] = []
const lead: Message[] = []
const by = new Map<string, MessageTurn>()
const by = new Map<string, { turn: MessageTurn; index: number }>()
let compact: { turn: MessageTurn; index: number } | undefined

for (const msg of messages) {
if (msg.role === "user") {
if (boundary && msg.id >= boundary) break
const turn = { id: msg.id, user: msg, assistant: [] }
const item = { turn, index: result.length }
result.push(turn)
by.set(msg.id, turn)
by.set(msg.id, item)
if (isCompact(msg, parts)) compact = item
continue
}

if (msg.role !== "assistant") continue
const turn = msg.parentID ? by.get(msg.parentID) : undefined
if (turn) {
const parent = msg.parentID ? by.get(msg.parentID) : undefined
if (parent) {
const turn = compact && parent.index < compact.index ? compact.turn : parent.turn
turn.assistant.push(msg)
continue
}
if (msg.parentID) {
if (compact) {
compact.turn.assistant.push(msg)
continue
}
lead.push(msg)
continue
}
Expand All @@ -78,8 +103,12 @@ export function messageTurns(messages: Message[], boundary?: string): MessageTur
return [...partials(lead), ...result]
}

export function visibleMessages(messages: Message[], boundary?: string): Message[] {
return messageTurns(messages, boundary).flatMap((turn) =>
export function visibleMessages(
messages: Message[],
boundary?: string,
parts?: (msg: Message) => Message["parts"],
): Message[] {
return messageTurns(messages, boundary, parts).flatMap((turn) =>
turn.partial ? turn.assistant : [turn.user, ...turn.assistant],
)
}
Expand All @@ -106,7 +135,7 @@ export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []
})
}

function active(messages: Message[], status: SessionStatusInfo) {
function active(messages: Message[], status: SessionStatusInfo, parts?: (msg: Message) => Message["parts"]) {
let latest = true
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
Expand All @@ -118,29 +147,30 @@ function active(messages: Message[], status: SessionStatusInfo) {
if (msg.error) continue
if (msg.finish && !resumable) continue
if (!msg.parentID) break
const parent = messages.find((item) => item.id === msg.parentID)
if (!parent) return msg.parentID
const id = target(messages, i, msg.parentID, parts)
const parent = messages.find((item) => item.id === id)
if (!parent) return id
if (parent.role === "user") return parent.id
break
}

return undefined
}

function done(messages: Message[]) {
function done(messages: Message[], parts?: (msg: Message) => Message["parts"]) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (!msg || msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") return msg.parentID
if (msg.error) return msg.parentID
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return msg.parentID
if (!msg || msg.role !== "assistant" || !msg.parentID) continue
if (typeof msg.time?.completed === "number") return target(messages, i, msg.parentID, parts)
if (msg.error) return target(messages, i, msg.parentID, parts)
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return target(messages, i, msg.parentID, parts)
}
return undefined
}

function pending(messages: Message[]) {
function pending(messages: Message[], parts?: (msg: Message) => Message["parts"]) {
const users = messages.filter((msg) => msg.role === "user")
const id = done(messages)
const id = done(messages, parts)
if (!id) return users[0]?.id

const idx = users.findIndex((msg) => msg.id === id)
Expand All @@ -149,23 +179,31 @@ function pending(messages: Message[]) {

// Find the user message whose turn the server is actively processing.
// Any user message after this one is "queued" (waiting for its turn).
export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) {
const id = active(messages, status)
export function activeUserMessageID(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
) {
const id = active(messages, status, parts)
if (id) return id
if (status.type === "idle") return undefined
return pending(messages)
return pending(messages, parts)
}

export function queuedUserMessageIDs(messages: Message[], status: SessionStatusInfo) {
export function queuedUserMessageIDs(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
) {
if (status.type === "idle") return []
const users = messages.filter((msg) => msg.role === "user")
const running = active(messages, status)
const running = active(messages, status, parts)
if (running) {
const idx = users.findIndex((msg) => msg.id === running)
if (idx < 0) return users.map((msg) => msg.id)
return users.slice(idx + 1).map((msg) => msg.id)
}
const id = pending(messages)
const id = pending(messages, parts)
const idx = id ? users.findIndex((msg) => msg.id === id) : -1
if (idx < 0) return []
return users.slice(idx + 1).map((msg) => msg.id)
Expand Down
4 changes: 3 additions & 1 deletion packages/kilo-vscode/webview-ui/src/context/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2304,7 +2304,9 @@ export const SessionProvider: ParentComponent = (props) => {
const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))

function visible(sessionID: string) {
return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID)
return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID, (msg) =>
getParts(msg.id),
)
}

const revert = createMemo(() => {
Expand Down
9 changes: 8 additions & 1 deletion packages/kilo-vscode/webview-ui/src/types/messages/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ export interface StepFinishPart extends BasePart {
}
}

export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
export interface CompactionPart extends BasePart {
type: "compaction"
auto: boolean
overflow?: boolean
tail_start_id?: string
}

export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart | CompactionPart

// Part delta for streaming updates
export interface PartDelta {
Expand Down
66 changes: 66 additions & 0 deletions packages/opencode/src/kilocode/session/message-order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { MessageV2 } from "@/session/message-v2"

const chronology = new WeakMap<MessageV2.WithParts, number>()

export namespace KiloSessionMessageOrder {
/** Preserve chronological order before model-facing projections rearrange messages. */
export function annotate(msgs: MessageV2.WithParts[]) {
for (const [index, msg] of msgs.entries()) chronology.set(msg, index)
return msgs
}

export function compare(a: MessageV2.WithParts, b: MessageV2.WithParts, indexA = -1, indexB = -1) {
if (a.info.time.created !== b.info.time.created) return a.info.time.created - b.info.time.created
const sequenceA = chronology.get(a)
const sequenceB = chronology.get(b)
if (sequenceA !== undefined && sequenceB !== undefined && sequenceA !== sequenceB) return sequenceA - sequenceB
return indexA - indexB
}

/** Derive active messages by chronology while keeping queued tasks in model-facing projection order. */
export function latest(msgs: MessageV2.WithParts[]) {
let user: MessageV2.WithParts | undefined
let assistant: MessageV2.WithParts | undefined
let finished: MessageV2.WithParts | undefined
let userIndex = -1
let assistantIndex = -1
let finishedIndex = -1

for (const [index, msg] of msgs.entries()) {
const info = msg.info
if (info.role === "user" && (!user || compare(msg, user, index, userIndex) > 0)) {
user = msg
userIndex = index
}
if (info.role === "assistant" && (!assistant || compare(msg, assistant, index, assistantIndex) > 0)) {
assistant = msg
assistantIndex = index
}
if (info.role === "assistant" && info.finish && (!finished || compare(msg, finished, index, finishedIndex) > 0)) {
finished = msg
finishedIndex = index
}
}

const pivot = msgs.findLastIndex((msg) => msg.info.role === "assistant" && msg.info.finish)
const tasks = msgs
.slice(pivot + 1)
.reverse()
.flatMap((msg) =>
msg.parts.filter(
(part): part is MessageV2.CompactionPart | MessageV2.SubtaskPart =>
part.type === "compaction" || part.type === "subtask",
),
)

return {
user: user?.info.role === "user" ? user.info : undefined,
assistant: assistant?.info.role === "assistant" ? assistant.info : undefined,
finished: finished?.info.role === "assistant" ? finished.info : undefined,
userMessage: user,
assistantMessage: assistant,
finishedMessage: finished,
tasks,
}
}
}
Loading
Loading