import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";import { describe, expect, it } from "vite-plus/test"; import { ApprovalRequestId, EventId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, type OrchestrationThread, type OrchestrationThreadActivity,} from "@t3tools/contracts"; import { agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, type WorkLogEntry,} from "./threadActivity"; const singleSelectQuestion = { id: "runtime", header: "Runtime", question: "Which runtime should be used?", options: [ { label: "Go", description: "One binary" }, { label: "Node.js", description: "Reuse TypeScript" }, ], multiSelect: false,} as const; const multiSelectQuestion = { id: "scope", header: "Scope", question: "Which data should be collected?", options: [ { label: "Orders", description: "Receipts" }, { label: "Listings", description: "Inventory" }, ], multiSelect: true,} as const; const nativeQuestion = { id: "choice", header: "File", question: "Which file should be used?", options: [ { label: "Use this", description: "First file", value: " choice " }, { label: "Use this", description: "Second file", value: "choice" }, ], multiSelect: false, allowCustomAnswer: false,} as const; describe("pending user input answers", () => { it("accepts free-text answers to async questions without options", () => { const question = { id: "0", header: "Question", question: "What should it be named?", options: [], allowCustomAnswer: true, multiSelect: false, }; const requested = makeActivity({ id: EventId.make("async-question"), kind: "user-input.requested", summary: "User input requested", createdAt: "2026-09-03T00:00:00.000Z", payload: { requestId: "async-1", responseMode: "message", questions: [question] }, }); const questions = derivePendingRequests([requested]).userInputs[0]?.questions; expect(questions).toEqual([question]); expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ "0": "Example", }); }); it("preserves native choice values and custom-answer rules from activities", () => { const requested = makeActivity({ id: EventId.make("native-question"), kind: "user-input.requested", summary: "User input requested", createdAt: "2026-09-02T00:00:00.000Z", payload: { requestId: "interaction_1", questions: [nativeQuestion, singleSelectQuestion], }, }); expect(derivePendingRequests([requested]).userInputs).toEqual([ { requestId: "interaction_1", createdAt: requested.createdAt, dismissible: false, questions: [nativeQuestion, singleSelectQuestion], }, ]); }); it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( singleSelectQuestion, { selectedOptionValues: ["Go"] }, "Node.js", ), ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); const ordersAndListings = togglePendingUserInputOptionSelection( multiSelectQuestion, orders, "Listings", ); expect(ordersAndListings).toEqual({ customAnswer: "", selectedOptionValues: ["Orders", "Listings"], }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); const paddedOrders = togglePendingUserInputOptionSelection( multiSelectQuestion, undefined, " Orders ", ); expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), ).toEqual({ customAnswer: "" }); }); it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { runtime: { selectedOptionValues: ["Go"] }, scope: { selectedOptionValues: ["Orders", "Listings"] }, }), ).toEqual({ runtime: "Go", scope: ["Orders", "Listings"], }); }); it("clears selected options while a custom answer is active", () => { expect( setPendingUserInputCustomAnswer( multiSelectQuestion, { selectedOptionValues: ["Orders", "Listings"] }, "Orders first", ), ).toEqual({ customAnswer: "Orders first" }); }); it("matches selected options against normalized legacy labels", () => { expect( isPendingUserInputOptionSelected( multiSelectQuestion, { selectedOptionValues: ["Orders"] }, " Orders ", ), ).toBe(true); expect( isPendingUserInputOptionSelected( multiSelectQuestion, { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, " Orders ", ), ).toBe(false); }); it("keeps custom answers enabled for legacy questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion], { runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " }, }), ).toEqual({ runtime: "Use Bun" }); }); it("keeps duplicate labels and whitespace-sensitive native values separate", () => { const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice "); expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true); expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({ choice: " choice ", }); const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice"); expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false); expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({ choice: "choice", }); }); it("keeps exact native values in multi-select answers", () => { const question = { ...nativeQuestion, multiSelect: true }; const first = togglePendingUserInputOptionSelection(question, undefined, " choice "); const both = togglePendingUserInputOptionSelection(question, first, "choice"); expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({ choice: [" choice ", "choice"], }); const second = togglePendingUserInputOptionSelection(question, both, " choice "); expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({ choice: ["choice"], }); }); it("ignores custom answers when a question only accepts choices", () => { const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" }; expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft); expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({ choice: " choice ", }); }); it.each([ { customAnswer: "Other" }, { selectedOptionValues: ["Use this"] }, { selectedOptionValues: ["not offered"] }, { selectedOptionValues: [" choice "] }, ])("requires an offered value for a choice-only question: %j", (draft) => { expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull(); });}); function makeActivity( input: Partial<OrchestrationThreadActivity> & Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,): OrchestrationThreadActivity { return { tone: "info", payload: {}, turnId: null, ...input, };} function makeThread( input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">,): OrchestrationThread { return { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, pullRequests: [], latestTurn: null, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], session: null, ...input, settledOverride: input.settledOverride ?? null, settledAt: input.settledAt ?? null, };} describe("buildThreadFeed", () => { it("reuses unchanged feed and presentation rows during an assistant text update", () => { const completedTurnId = TurnId.make("completed-turn"); const activeTurnId = TurnId.make("active-turn"); const thread = makeThread({ id: ThreadId.make("feed-reuse"), projectId: ProjectId.make("project-1"), title: "Feed reuse", messages: [ { id: MessageId.make("completed-message"), role: "assistant", text: "Completed response", turnId: completedTurnId, streaming: false, createdAt: "2026-04-01T00:00:01.000Z", updatedAt: "2026-04-01T00:00:01.000Z", }, { id: MessageId.make("streaming-message"), role: "assistant", text: "Current response", turnId: activeTurnId, streaming: true, createdAt: "2026-04-01T00:00:04.000Z", updatedAt: "2026-04-01T00:00:04.000Z", }, ], activities: [ makeActivity({ id: EventId.make("completed-tool"), kind: "tool.completed", summary: "Read files", createdAt: "2026-04-01T00:00:02.000Z", turnId: completedTurnId, payload: { itemType: "file_read", status: "completed" }, }), makeActivity({ id: EventId.make("active-tool"), kind: "tool.updated", summary: "Run checks", createdAt: "2026-04-01T00:00:03.000Z", turnId: activeTurnId, payload: { itemType: "command_execution", command: "vp test", status: "inProgress" }, }), ], }); const latestTurn = { turnId: activeTurnId, state: "running" as const, startedAt: "2026-04-01T00:00:03.000Z", completedAt: null, }; const expandedTurns = new Set([completedTurnId]); const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]); const previousFeed = buildThreadFeed(thread); const previousRows = deriveThreadFeedPresentation( previousFeed, latestTurn, expandedTurns, expandedGroups, latestTurn.startedAt, ); const updatedMessage = { ...thread.messages[1]!, text: "Current response with more text", updatedAt: "2026-04-01T00:00:05.000Z", }; const nextFeed = buildThreadFeed({ ...thread, messages: [thread.messages[0]!, updatedMessage], }); const nextRows = deriveThreadFeedPresentation( nextFeed, latestTurn, expandedTurns, expandedGroups, latestTurn.startedAt, ); expect(nextFeed).toHaveLength(previousFeed.length); expect(nextRows).toHaveLength(previousRows.length); for (const [before, after] of [ [previousFeed, nextFeed], [previousRows, nextRows], ] as const) { for (const [index, row] of after.entries()) { if (row.id === updatedMessage.id) { expect(row).not.toBe(before[index]); expect(row).toMatchObject({ message: updatedMessage }); } else { expect(row).toBe(before[index]); } } } expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true); expect(nextRows.some((row) => row.type === "activity-group")).toBe(true); }); it("regroups cached activities for message changes and pagination", () => { const messages = [2, 4].map((second) => ({ id: MessageId.make(`message-${second}`), role: "assistant" as const, text: second === 2 ? "" : "Response", streaming: false, turnId: null, createdAt: `2026-04-01T00:00:0${second}.000Z`, updatedAt: `2026-04-01T00:00:0${second}.000Z`, })); const thread = makeThread({ id: ThreadId.make("feed-regroup"), projectId: ProjectId.make("project-1"), title: "Feed grouping", messages, activities: [1, 3, 5].map((second) => makeActivity({ id: EventId.make(`work-${second}`), kind: "runtime.warning", summary: `Notice ${second}`, createdAt: `2026-04-01T00:00:0${second}.000Z`, }), ), }); const initial = buildThreadFeed(thread); expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]); const split = buildThreadFeed({ ...thread, messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!], }); expect(split.map((row) => row.id)).toEqual([ "work-1", "message-2", "work-3", "message-4", "work-5", ]); expect(split[0]).not.toBe(initial[0]); expect(split.at(-1)).toBe(initial.at(-1)); expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] }); const reordered = buildThreadFeed({ ...thread, messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }], }); expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]); expect(reordered[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }], }); const olderMessage = { ...messages[1]!, id: MessageId.make("older-message"), createdAt: "2026-04-01T00:00:00.000Z", }; const page = buildThreadFeed(thread, { loadedMessages: [messages[1]!], localMessages: [olderMessage], }); expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]); const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] }); expect(prepended.map((row) => row.id)).toEqual([ "older-message", "work-1", "message-4", "work-5", ]); expect(prepended.at(-1)).toBe(page.at(-1)); }); it("keeps context compaction as a standalone timeline row", () => { const thread = makeThread({ id: ThreadId.make("thread-context-compaction"), projectId: ProjectId.make("project-1"), title: "Context compaction", activities: [ makeActivity({ id: EventId.make("context-compaction"), kind: "context-compaction", tone: "info", summary: "Compacted context 899K → 19K tokens", createdAt: "2026-09-01T00:00:00.000Z", turnId: TurnId.make("turn-context-compaction"), }), ], }); const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set()); expect(presented).toMatchObject([ { type: "activity-group", id: "context-compaction", activities: [{ summary: "Compacted context 899K → 19K tokens" }], }, ]); }); it("keeps long Claude commands expandable without repeating them in full detail", () => { const command = `printf 'first line\nsecond line'\n&& printf done`; const thread = makeThread({ id: ThreadId.make("thread-long-command"), projectId: ProjectId.make("project-1"), title: "Long command", activities: [ makeActivity({ id: EventId.make("long-command"), kind: "tool.completed", tone: "tool", summary: "Command run", createdAt: "2026-09-01T00:00:00.000Z", payload: { itemType: "command_execution", title: "Command run", detail: `Bash: ${command}`, data: { toolName: "Bash", command }, }, }), ], }); const [group] = buildThreadFeed(thread); expect(group?.type).toBe("activity-group"); if (group?.type !== "activity-group") return; const [row] = group.activities; expect(row).toMatchObject({ detail: command, canExpand: true });Lines 1–500 of 3387. Load more
import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { describe, expect, it } from "vite-plus/test"; import { ApprovalRequestId, EventId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, type OrchestrationThread, type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, type WorkLogEntry, } from "./threadActivity"; const singleSelectQuestion = { id: "runtime", header: "Runtime", question: "Which runtime should be used?", options: [ { label: "Go", description: "One binary" }, { label: "Node.js", description: "Reuse TypeScript" }, ], multiSelect: false, } as const; const multiSelectQuestion = { id: "scope", header: "Scope", question: "Which data should be collected?", options: [ { label: "Orders", description: "Receipts" }, { label: "Listings", description: "Inventory" }, ], multiSelect: true, } as const; const nativeQuestion = { id: "choice", header: "File", question: "Which file should be used?", options: [ { label: "Use this", description: "First file", value: " choice " }, { label: "Use this", description: "Second file", value: "choice" }, ], multiSelect: false, allowCustomAnswer: false, } as const; describe("pending user input answers", () => { it("accepts free-text answers to async questions without options", () => { const question = { id: "0", header: "Question", question: "What should it be named?", options: [], allowCustomAnswer: true, multiSelect: false, }; const requested = makeActivity({ id: EventId.make("async-question"), kind: "user-input.requested", summary: "User input requested", createdAt: "2026-09-03T00:00:00.000Z", payload: { requestId: "async-1", responseMode: "message", questions: [question] }, }); const questions = derivePendingRequests([requested]).userInputs[0]?.questions; expect(questions).toEqual([question]); expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ "0": "Example", }); }); it("preserves native choice values and custom-answer rules from activities", () => { const requested = makeActivity({ id: EventId.make("native-question"), kind: "user-input.requested", summary: "User input requested", createdAt: "2026-09-02T00:00:00.000Z", payload: { requestId: "interaction_1", questions: [nativeQuestion, singleSelectQuestion], }, }); expect(derivePendingRequests([requested]).userInputs).toEqual([ { requestId: "interaction_1", createdAt: requested.createdAt, dismissible: false, questions: [nativeQuestion, singleSelectQuestion], }, ]); }); it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( singleSelectQuestion, { selectedOptionValues: ["Go"] }, "Node.js", ), ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); const ordersAndListings = togglePendingUserInputOptionSelection( multiSelectQuestion, orders, "Listings", ); expect(ordersAndListings).toEqual({ customAnswer: "", selectedOptionValues: ["Orders", "Listings"], }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); const paddedOrders = togglePendingUserInputOptionSelection( multiSelectQuestion, undefined, " Orders ", ); expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), ).toEqual({ customAnswer: "" }); }); it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { runtime: { selectedOptionValues: ["Go"] }, scope: { selectedOptionValues: ["Orders", "Listings"] }, }), ).toEqual({ runtime: "Go", scope: ["Orders", "Listings"], }); }); it("clears selected options while a custom answer is active", () => { expect( setPendingUserInputCustomAnswer( multiSelectQuestion, { selectedOptionValues: ["Orders", "Listings"] }, "Orders first", ), ).toEqual({ customAnswer: "Orders first" }); }); it("matches selected options against normalized legacy labels", () => { expect( isPendingUserInputOptionSelected( multiSelectQuestion, { selectedOptionValues: ["Orders"] }, " Orders ", ), ).toBe(true); expect( isPendingUserInputOptionSelected( multiSelectQuestion, { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, " Orders ", ), ).toBe(false); }); it("keeps custom answers enabled for legacy questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion], { runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " }, }), ).toEqual({ runtime: "Use Bun" }); }); it("keeps duplicate labels and whitespace-sensitive native values separate", () => { const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice "); expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true); expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({ choice: " choice ", }); const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice"); expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false); expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({ choice: "choice", }); }); it("keeps exact native values in multi-select answers", () => { const question = { ...nativeQuestion, multiSelect: true }; const first = togglePendingUserInputOptionSelection(question, undefined, " choice "); const both = togglePendingUserInputOptionSelection(question, first, "choice"); expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({ choice: [" choice ", "choice"], }); const second = togglePendingUserInputOptionSelection(question, both, " choice "); expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({ choice: ["choice"], }); }); it("ignores custom answers when a question only accepts choices", () => { const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" }; expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft); expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true); expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({ choice: " choice ", }); }); it.each([ { customAnswer: "Other" }, { selectedOptionValues: ["Use this"] }, { selectedOptionValues: ["not offered"] }, { selectedOptionValues: [" choice "] }, ])("requires an offered value for a choice-only question: %j", (draft) => { expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull(); }); }); function makeActivity( input: Partial<OrchestrationThreadActivity> & Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, ): OrchestrationThreadActivity { return { tone: "info", payload: {}, turnId: null, ...input, }; } function makeThread( input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">, ): OrchestrationThread { return { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, pullRequests: [], latestTurn: null, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], session: null, ...input, settledOverride: input.settledOverride ?? null, settledAt: input.settledAt ?? null, }; } describe("buildThreadFeed", () => { it("reuses unchanged feed and presentation rows during an assistant text update", () => { const completedTurnId = TurnId.make("completed-turn"); const activeTurnId = TurnId.make("active-turn"); const thread = makeThread({ id: ThreadId.make("feed-reuse"), projectId: ProjectId.make("project-1"), title: "Feed reuse", messages: [ { id: MessageId.make("completed-message"), role: "assistant", text: "Completed response", turnId: completedTurnId, streaming: false, createdAt: "2026-04-01T00:00:01.000Z", updatedAt: "2026-04-01T00:00:01.000Z", }, { id: MessageId.make("streaming-message"), role: "assistant", text: "Current response", turnId: activeTurnId, streaming: true, createdAt: "2026-04-01T00:00:04.000Z", updatedAt: "2026-04-01T00:00:04.000Z", }, ], activities: [ makeActivity({ id: EventId.make("completed-tool"), kind: "tool.completed", summary: "Read files", createdAt: "2026-04-01T00:00:02.000Z", turnId: completedTurnId, payload: { itemType: "file_read", status: "completed" }, }), makeActivity({ id: EventId.make("active-tool"), kind: "tool.updated", summary: "Run checks", createdAt: "2026-04-01T00:00:03.000Z", turnId: activeTurnId, payload: { itemType: "command_execution", command: "vp test", status: "inProgress" }, }), ], }); const latestTurn = { turnId: activeTurnId, state: "running" as const, startedAt: "2026-04-01T00:00:03.000Z", completedAt: null, }; const expandedTurns = new Set([completedTurnId]); const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]); const previousFeed = buildThreadFeed(thread); const previousRows = deriveThreadFeedPresentation( previousFeed, latestTurn, expandedTurns, expandedGroups, latestTurn.startedAt, ); const updatedMessage = { ...thread.messages[1]!, text: "Current response with more text", updatedAt: "2026-04-01T00:00:05.000Z", }; const nextFeed = buildThreadFeed({ ...thread, messages: [thread.messages[0]!, updatedMessage], }); const nextRows = deriveThreadFeedPresentation( nextFeed, latestTurn, expandedTurns, expandedGroups, latestTurn.startedAt, ); expect(nextFeed).toHaveLength(previousFeed.length); expect(nextRows).toHaveLength(previousRows.length); for (const [before, after] of [ [previousFeed, nextFeed], [previousRows, nextRows], ] as const) { for (const [index, row] of after.entries()) { if (row.id === updatedMessage.id) { expect(row).not.toBe(before[index]); expect(row).toMatchObject({ message: updatedMessage }); } else { expect(row).toBe(before[index]); } } } expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true); expect(nextRows.some((row) => row.type === "activity-group")).toBe(true); }); it("regroups cached activities for message changes and pagination", () => { const messages = [2, 4].map((second) => ({ id: MessageId.make(`message-${second}`), role: "assistant" as const, text: second === 2 ? "" : "Response", streaming: false, turnId: null, createdAt: `2026-04-01T00:00:0${second}.000Z`, updatedAt: `2026-04-01T00:00:0${second}.000Z`, })); const thread = makeThread({ id: ThreadId.make("feed-regroup"), projectId: ProjectId.make("project-1"), title: "Feed grouping", messages, activities: [1, 3, 5].map((second) => makeActivity({ id: EventId.make(`work-${second}`), kind: "runtime.warning", summary: `Notice ${second}`, createdAt: `2026-04-01T00:00:0${second}.000Z`, }), ), }); const initial = buildThreadFeed(thread); expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]); const split = buildThreadFeed({ ...thread, messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!], }); expect(split.map((row) => row.id)).toEqual([ "work-1", "message-2", "work-3", "message-4", "work-5", ]); expect(split[0]).not.toBe(initial[0]); expect(split.at(-1)).toBe(initial.at(-1)); expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] }); const reordered = buildThreadFeed({ ...thread, messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }], }); expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]); expect(reordered[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }], }); const olderMessage = { ...messages[1]!, id: MessageId.make("older-message"), createdAt: "2026-04-01T00:00:00.000Z", }; const page = buildThreadFeed(thread, { loadedMessages: [messages[1]!], localMessages: [olderMessage], }); expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]); const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] }); expect(prepended.map((row) => row.id)).toEqual([ "older-message", "work-1", "message-4", "work-5", ]); expect(prepended.at(-1)).toBe(page.at(-1)); }); it("keeps context compaction as a standalone timeline row", () => { const thread = makeThread({ id: ThreadId.make("thread-context-compaction"), projectId: ProjectId.make("project-1"), title: "Context compaction", activities: [ makeActivity({ id: EventId.make("context-compaction"), kind: "context-compaction", tone: "info", summary: "Compacted context 899K → 19K tokens", createdAt: "2026-09-01T00:00:00.000Z", turnId: TurnId.make("turn-context-compaction"), }), ], }); const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set()); expect(presented).toMatchObject([ { type: "activity-group", id: "context-compaction", activities: [{ summary: "Compacted context 899K → 19K tokens" }], }, ]); }); it("keeps long Claude commands expandable without repeating them in full detail", () => { const command = `printf 'first line\nsecond line'\n&& printf done`; const thread = makeThread({ id: ThreadId.make("thread-long-command"), projectId: ProjectId.make("project-1"), title: "Long command", activities: [ makeActivity({ id: EventId.make("long-command"), kind: "tool.completed", tone: "tool", summary: "Command run", createdAt: "2026-09-01T00:00:00.000Z", payload: { itemType: "command_execution", title: "Command run", detail: `Bash: ${command}`, data: { toolName: "Bash", command }, }, }), ], }); const [group] = buildThreadFeed(thread); expect(group?.type).toBe("activity-group"); if (group?.type !== "activity-group") return; const [row] = group.activities; expect(row).toMatchObject({ detail: command, canExpand: true });