diff --git a/.changeset/tui-message-time-order.md b/.changeset/tui-message-time-order.md new file mode 100644 index 00000000000..8e3bc72f59e --- /dev/null +++ b/.changeset/tui-message-time-order.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix TUI sessions where new turns stopped appearing until the session was reopened diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 703c9d0e8f6..1f21520a2cb 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -38,6 +38,7 @@ import path from "path" import { useKV } from "./kv" import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change +import { at, recent, slot } from "../kilocode/message-order" // kilocode_change import { useToast } from "../ui/toast" // kilocode_change import { usePermission } from "./permission" @@ -501,7 +502,7 @@ export const { setStore("message", event.properties.info.sessionID, [event.properties.info]) break } - const result = search(messages, event.properties.info.id, (m) => m.id) + const result = slot(messages, event.properties.info) // kilocode_change - order by created time, ids wrap if (result.found) { setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) break @@ -537,7 +538,7 @@ export const { case "message.removed": { touchMessage(event.properties.sessionID, event.properties.messageID) const messages = store.message[event.properties.sessionID] - const result = search(messages, event.properties.messageID, (m) => m.id) + const result = at(messages, event.properties.messageID) // kilocode_change - list is time-ordered, not id-sorted if (result.found) { setStore( "message", @@ -680,7 +681,7 @@ export const { setStore("message", info.sessionID, [info]) break } - const match = search(messages, info.id, (item) => item.id) + const match = slot(messages, info) // kilocode_change - order by created time, ids wrap if (match.found) { setStore("message", info.sessionID, match.index, reconcile(info)) break @@ -710,7 +711,7 @@ export const { touchMessage(event.data.sessionID, event.data.messageID) const messages = store.message[event.data.sessionID] if (!messages) break - const match = search(messages, event.data.messageID, (item) => item.id) + const match = at(messages, event.data.messageID) // kilocode_change - list is time-ordered, not id-sorted if (!match.found) break setStore( "message", @@ -1008,9 +1009,11 @@ export const { (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id), ), ) - const removed = infos.slice(0, -100) - const visible = infos.slice(-100) + // kilocode_change start - window by created time so wrapped ids stay visible + const visible = recent(infos) const visibleIDs = new Set(visible.map((message) => message.id)) + const removed = infos.filter((message) => !visibleIDs.has(message.id)) + // kilocode_change end for (const message of messages.data ?? []) { if (!visibleIDs.has(message.info.id)) { delete draft.part[message.info.id] diff --git a/packages/tui/src/kilocode/message-order.ts b/packages/tui/src/kilocode/message-order.ts new file mode 100644 index 00000000000..011f9dbfaf3 --- /dev/null +++ b/packages/tui/src/kilocode/message-order.ts @@ -0,0 +1,22 @@ +type Stamped = { id: string; time: { created: number } } + +export function older(a: Stamped, b: Stamped) { + if (a.time.created !== b.time.created) return a.time.created - b.time.created + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +export function at(list: readonly { id: string }[], id: string) { + const index = list.findIndex((item) => item.id === id) + return { found: index >= 0, index } +} + +export function slot(list: readonly T[], item: T) { + const hit = at(list, item.id) + if (hit.found) return hit + const index = list.findIndex((entry) => older(item, entry) < 0) + return { found: false, index: index < 0 ? list.length : index } +} + +export function recent(list: readonly T[], cap = 100) { + return list.toSorted(older).slice(-cap) +} diff --git a/packages/tui/test/kilocode/message-order-sync.test.tsx b/packages/tui/test/kilocode/message-order-sync.test.tsx new file mode 100644 index 00000000000..f6a032a8f1c --- /dev/null +++ b/packages/tui/test/kilocode/message-order-sync.test.tsx @@ -0,0 +1,138 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { GlobalEvent } from "@kilocode/sdk/v2" +import { tmpdir } from "../fixture/fixture" +import { json, mount, wait } from "../cli/cmd/tui/sync-fixture" + +const sessionID = "ses_order" +const partID = "prt_order" +const directory = "/tmp/opencode/packages/tui" +let seq = 0 +const session = { + id: sessionID, + title: "order", + time: { created: 0, updated: 0 }, + version: "1.15.13", + directory, +} +const base = { + sessionID, + role: "assistant" as const, + agent: "build", + modelID: "model", + providerID: "test", + mode: "build", + parentID: "msg_user", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, +} +const old = { ...base, id: "msg_ff0cb2300001Z6YIo5V52u114f", time: { created: 1, completed: 2 } } +const next = { ...base, id: "msg_019f1d3da001955TwEJ8qKEbj3", time: { created: 3 } } + +function wrap(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory, project: "proj_test", payload } +} + +function sync1(id: string, info: (typeof base & { id: string; time: { created: number } }) | undefined): GlobalEvent { + return { + directory, + project: "proj_test", + payload: { + type: "sync", + syncEvent: { + id, + type: "message.updated.1", + seq: ++seq, + aggregateID: sessionID, + data: { sessionID, info }, + }, + }, + } as GlobalEvent +} + +function serve(infos: (typeof old)[]) { + return (url: URL) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) { + return json(infos.map((info) => ({ info, parts: [] }))) + } + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + return undefined + } +} + +test("a later message with a lexicographically earlier id stays at the tail", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}/message`) { + return json([{ info: old, parts: [{ id: partID, sessionID, messageID: old.id, type: "text", text: "old" }] }]) + } + return serve([])(url) + }, tmp.path) + try { + await sync.session.sync(sessionID) + await wait(() => sync.data.message[sessionID]?.some((item) => item.id === old.id) ?? false) + emit( + wrap({ + id: "evt_next", + type: "message.updated", + properties: { sessionID, info: next }, + }), + ) + await wait(() => sync.data.message[sessionID]?.some((item) => item.id === next.id) ?? false) + const ids = (sync.data.message[sessionID] ?? []).map((item) => item.id) + expect(ids[0]).toBe(old.id) + expect(ids.at(-1)).toBe(next.id) + } finally { + app.renderer.destroy() + } +}) + +test("eviction at the window cap drops the oldest message, not the newest", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const infos = Array.from({ length: 100 }, (_, index) => ({ + ...base, + id: `msg_ff0c${String(index).padStart(4, "0")}`, + time: { created: index + 1, completed: index + 2 }, + })) + const { app, emit, sync } = await mount(serve(infos), tmp.path) + try { + await sync.session.sync(sessionID) + await wait(() => sync.data.message[sessionID]?.length === 100) + emit( + wrap({ + id: "evt_next", + type: "message.updated", + properties: { sessionID, info: { ...next, time: { created: 200 } } }, + }), + ) + await wait(() => sync.data.message[sessionID]?.some((item) => item.id === next.id) ?? false) + const ids = (sync.data.message[sessionID] ?? []).map((item) => item.id) + expect(ids).toHaveLength(100) + expect(ids.at(-1)).toBe(next.id) + expect(ids).not.toContain(infos[0].id) + expect(ids[0]).toBe(infos[1].id) + } finally { + app.renderer.destroy() + } +}) + +test("the versioned sync channel also keeps a wrapped id at the tail", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount(serve([old]), tmp.path) + try { + await sync.session.sync(sessionID) + await wait(() => sync.data.message[sessionID]?.some((item) => item.id === old.id) ?? false) + emit(sync1("evt_next_v1", next)) + await wait(() => sync.data.message[sessionID]?.some((item) => item.id === next.id) ?? false) + const ids = (sync.data.message[sessionID] ?? []).map((item) => item.id) + expect(ids[0]).toBe(old.id) + expect(ids.at(-1)).toBe(next.id) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/kilocode/message-order.test.ts b/packages/tui/test/kilocode/message-order.test.ts new file mode 100644 index 00000000000..2f98dd6fb74 --- /dev/null +++ b/packages/tui/test/kilocode/message-order.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" +import { at, older, recent, slot } from "../../src/kilocode/message-order" + +const old = { id: "msg_ff0cb2300001Z6YIo5V52u114f", time: { created: 1 } } +const next = { id: "msg_019f1d3da001955TwEJ8qKEbj3", time: { created: 2 } } + +test("a later message with an earlier id sorts after the older one", () => { + expect(older(old, next)).toBeLessThan(0) + expect(older(next, old)).toBeGreaterThan(0) +}) + +test("equal created times fall back to id order", () => { + const a = { id: "msg_a", time: { created: 1 } } + const b = { id: "msg_b", time: { created: 1 } } + expect(older(a, b)).toBeLessThan(0) + expect(older(a, a)).toBe(0) +}) + +test("a later message with an earlier id inserts at the tail", () => { + expect(slot([old], next)).toEqual({ found: false, index: 1 }) +}) + +test("slot finds an existing message by id", () => { + expect(slot([old, next], next)).toEqual({ found: true, index: 1 }) +}) + +test("the message window keeps newest by created time, not id", () => { + expect(recent([next, old], 1).map((item) => item.id)).toEqual([next.id]) +}) + +test("lookup by id is linear so time-ordered lists still find removals", () => { + expect(at([old, next], next.id)).toEqual({ found: true, index: 1 }) + expect(at([old, next], "msg_missing")).toEqual({ found: false, index: -1 }) +})