diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54edcc9..4cd7d08 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -197,7 +197,10 @@ producing model invocation source. Completion activity marks later text from the same source as finalizing instead of starting a second live response. Model activity carries the target durable message sequence. The browser places each reasoning summary before that assistant message when timestamps tie or are -unavailable. Unanchored reasoning retains its own chronological position. +unavailable. During live execution, activity that arrives before its durable +assistant message renders below the latest user turn until durable Work log data +replaces it; terminal unanchored activity remains historical. Unanchored +reasoning retains its own chronological position. `WaitingInputRound` is a monotonic `int64` Attribute bounded by JavaScript's safe integer maximum. With no pending question, `AwaitUser.WaitFor` increments it only when steered, queued, and current-Plan execution Channels are empty. A diff --git a/web/packages/superagent-ui/README.md b/web/packages/superagent-ui/README.md index 16689a4..252bc24 100644 --- a/web/packages/superagent-ui/README.md +++ b/web/packages/superagent-ui/README.md @@ -67,6 +67,11 @@ conversation structure. Activity with a durable `messageSequence`, including input-consumption hints, belongs to that message's turn instead of the global historical section. Unanchored input-consumption hints remain reconciliation-only and are not shown. +While execution is live, work activity at or after the latest durable message +remains in that current turn even when its durable assistant message has not +reached the Snapshot yet. Unanchored live tool events render below that turn +until durable Work log data replaces them. Terminal unanchored activity remains +in the global historical section. `ConversationTurn.operations` preserves causal order: each model operation is followed by the tools it requested. Adjacent identical calls in one model batch diff --git a/web/packages/superagent-ui/src/ConversationView.test.tsx b/web/packages/superagent-ui/src/ConversationView.test.tsx index 593a576..7fa7ca7 100644 --- a/web/packages/superagent-ui/src/ConversationView.test.tsx +++ b/web/packages/superagent-ui/src/ConversationView.test.tsx @@ -163,6 +163,60 @@ describe("ConversationView", () => { expect(container).not.toHaveTextContent("recovered events"); }); + it("does not show live activity as historical before Snapshot catches up", () => { + render( + , + ); + + expect(screen.queryByText(/Historical activity/)).not.toBeInTheDocument(); + expect(screen.getByText("Work log")).toBeInTheDocument(); + expect( + screen.getByText("Current activity · 1 event").closest("details") + ?.parentElement, + ).toHaveClass("sa-conversation-turn"); + expect(screen.getByText("Calling read_file.")).toBeInTheDocument(); + }); + it("updates a running tool duration without announcing every tick", () => { vi.useFakeTimers(); vi.setSystemTime(base + 2_000); diff --git a/web/packages/superagent-ui/src/ConversationView.tsx b/web/packages/superagent-ui/src/ConversationView.tsx index 08fac3d..e6b8c75 100644 --- a/web/packages/superagent-ui/src/ConversationView.tsx +++ b/web/packages/superagent-ui/src/ConversationView.tsx @@ -322,16 +322,18 @@ function WorkLog({ ); } -function HistoricalActivity({ +function ActivityDetails({ entries, + label = "Historical activity", }: { entries: readonly (TimelineActivityEntry | TimelineLiveTextEntry)[]; + label?: string; }) { if (entries.length === 0) return null; return (
- Historical activity · {entries.length} event + {label} · {entries.length} event {entries.length === 1 ? "" : "s"}
    @@ -420,7 +422,7 @@ export function ConversationView({ return (
    - + {presentation.turns.map((turn) => (
    {entry.value.content} ))} - + {turn.questions.map((question) => (
    @@ -464,6 +466,10 @@ export function ConversationView({ )}
    ))} +
    ))} {assistant?.value ? ( diff --git a/web/packages/superagent-ui/src/conversationPresentation.test.ts b/web/packages/superagent-ui/src/conversationPresentation.test.ts index 6b48ebc..48f9782 100644 --- a/web/packages/superagent-ui/src/conversationPresentation.test.ts +++ b/web/packages/superagent-ui/src/conversationPresentation.test.ts @@ -254,6 +254,75 @@ describe("conversation presentation", () => { expect(required(view.turns[1]).activities).toContain(activity); }); + it("keeps live unanchored activity in the current user turn", () => { + const messages = [ + message(1, "user", "first"), + message(2, "assistant", "first answer"), + message(3, "user", "continue"), + ]; + const toolActivity: TimelineActivityEntry = { + resumeToken: "live-tool", + source: "tool-source", + createdAt: iso(4_000), + value: { + kind: "model_tool_call", + message: "Calling read_file.", + callId: "not-durable-yet", + toolName: "read_file", + messageSequence: 5, + }, + }; + const modelActivity: TimelineActivityEntry = { + resumeToken: "live-model", + source: "model-source", + createdAt: iso(5_000), + value: { + kind: "model_started", + message: "Calling model.", + messageSequence: 6, + }, + }; + + const view = buildConversationPresentation({ + messages, + activities: [toolActivity, modelActivity], + isModelRunning: true, + isExecutionLive: true, + }); + + expect(view.earlierActivity).toHaveLength(0); + expect(required(view.turns[1]).activities).toContain(toolActivity); + expect(required(view.turns[1]).liveActivities).toEqual([toolActivity]); + expect(required(view.turns[1]).models).toHaveLength(1); + }); + + it("leaves unanchored terminal activity in historical activity", () => { + const messages = [ + message(1, "user", "first"), + message(2, "assistant", "done"), + ]; + const activity: TimelineActivityEntry = { + resumeToken: "unanchored-terminal", + source: "tool-source", + createdAt: iso(3_000), + value: { + kind: "model_tool_call", + message: "Calling read_file.", + callId: "missing", + toolName: "read_file", + messageSequence: 4, + }, + }; + + const view = buildConversationPresentation({ + messages, + activities: [activity], + isExecutionLive: false, + }); + + expect(view.earlierActivity).toEqual([activity]); + }); + it("keeps current and resolved waits separate from tool execution", () => { const messages = [ message(1, "user", "build"), diff --git a/web/packages/superagent-ui/src/conversationPresentation.ts b/web/packages/superagent-ui/src/conversationPresentation.ts index 4d1acb4..0bc6390 100644 --- a/web/packages/superagent-ui/src/conversationPresentation.ts +++ b/web/packages/superagent-ui/src/conversationPresentation.ts @@ -86,6 +86,7 @@ export interface ConversationTurn { operations: ConversationWorkItem[]; questions: ConversationQuestionItem[]; activities: TimelineActivityEntry[]; + liveActivities: TimelineActivityEntry[]; historicalActivities: TimelineActivityEntry[]; durationMs: number | null; workDurationMs: number | null; @@ -110,6 +111,13 @@ export interface ConversationPresentationInput { const MODEL_TERMINAL = new Set(["model_completed", "model_failed"]); const MODEL_LIFECYCLE = new Set(["model_started", ...MODEL_TERMINAL]); +const LIVE_WORK_ACTIVITY = new Set([ + ...MODEL_LIFECYCLE, + "model_tool_call", + "tool_progress", + "tool_failed", + "tool_completed", +]); const HISTORICAL_ACTIVITY = new Set([ "compacted", "compaction_failed", @@ -202,6 +210,7 @@ function groupDurableTurns(messages: readonly TimelineSequencedMessage[]) { operations: [], questions: [], activities: [], + liveActivities: [], historicalActivities: [], durationMs: null, workDurationMs: null, @@ -227,6 +236,10 @@ export function buildConversationPresentation( (timestamp(left.createdAt) ?? 0) - (timestamp(right.createdAt) ?? 0), ); const { turns, sequenceToTurn } = groupDurableTurns(messages); + const currentLiveTurn = liveTurnForUnanchoredActivity( + turns, + input.isExecutionLive, + ); const earlierActivity: (TimelineActivityEntry | TimelineLiveTextEntry)[] = []; const resultByCall = new Map(); const callToTurn = new Map(); @@ -267,8 +280,15 @@ export function buildConversationPresentation( : sequenceToTurn.get(activity.value.messageSequence); if (turn === undefined && HISTORICAL_ACTIVITY.has(activity.value.kind)) turn = nearestTurnByTime(turns, activity.createdAt); + const isLiveFallback = + turn === undefined && + LIVE_WORK_ACTIVITY.has(activity.value.kind) && + activityBelongsToLiveTurn(currentLiveTurn, activity.createdAt); + if (isLiveFallback) turn = currentLiveTurn?.turn; if (turn) { turn.activities.push(activity); + if (isLiveFallback && !MODEL_LIFECYCLE.has(activity.value.kind)) + turn.liveActivities.push(activity); if (HISTORICAL_ACTIVITY.has(activity.value.kind)) turn.historicalActivities.push(activity); } else if ( @@ -293,6 +313,7 @@ export function buildConversationPresentation( operations: [], questions: [], activities: [], + liveActivities: [], historicalActivities: [], durationMs: null, workDurationMs: null, @@ -499,12 +520,22 @@ export function buildConversationPresentation( const lastMessageSequence = messages.at(-1)?.sequence; const turn = anchored?.value.messageSequence == null - ? undefined + ? activityBelongsToLiveTurn( + currentLiveTurn, + modelActivities[0]?.createdAt, + ) + ? currentLiveTurn?.turn + : undefined : (sequenceToTurn.get(anchored.value.messageSequence) ?? (lastMessageSequence !== undefined && messageSequence === lastMessageSequence + 1 ? turns.at(-1) - : undefined)); + : activityBelongsToLiveTurn( + currentLiveTurn, + modelActivities[0]?.createdAt, + ) + ? currentLiveTurn?.turn + : undefined)); if (!turn) { earlierActivity.push(...modelActivities); const reasoning = reasoningBySource.get(source); @@ -635,6 +666,39 @@ export function buildConversationPresentation( return { turns, earlierActivity }; } +interface LiveTurnBoundary { + turn: ConversationTurn; + observedThrough: number; +} + +function liveTurnForUnanchoredActivity( + turns: readonly ConversationTurn[], + isExecutionLive: boolean | undefined, +): LiveTurnBoundary | undefined { + if (isExecutionLive === false) return undefined; + const turn = turns.at(-1); + const observedThrough = Math.max( + ...(turn?.messages + .map((entry) => timestamp(entry.message.createdAt)) + .filter((value): value is number => value !== null) ?? []), + ); + return turn === undefined || !Number.isFinite(observedThrough) + ? undefined + : { turn, observedThrough }; +} + +function activityBelongsToLiveTurn( + boundary: LiveTurnBoundary | undefined, + createdAt: string | undefined, +): boolean { + const activityTime = timestamp(createdAt); + return ( + boundary !== undefined && + activityTime !== null && + activityTime >= boundary.observedThrough + ); +} + function nearestTurnByTime( turns: readonly ConversationTurn[], createdAt: string,