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: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions web/packages/superagent-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions web/packages/superagent-ui/src/ConversationView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ConversationView
isExecutionLive
isModelRunning
messages={[
{
sequence: 1,
message: {
role: "user",
content: "build it",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: new Date(base + 1_000).toISOString(),
},
},
]}
activities={[
{
resumeToken: "live-model",
source: "model-source",
createdAt: new Date(base + 2_000).toISOString(),
value: {
kind: "model_started",
message: "Calling model.",
messageSequence: 2,
},
},
{
resumeToken: "live-tool",
source: "tool-source",
createdAt: new Date(base + 3_000).toISOString(),
value: {
kind: "model_tool_call",
message: "Calling read_file.",
callId: "not-durable-yet",
toolName: "read_file",
messageSequence: 3,
},
},
]}
/>,
);

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);
Expand Down
14 changes: 10 additions & 4 deletions web/packages/superagent-ui/src/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<details className="sa-earlier-activity">
<summary>
Historical activity · {entries.length} event
{label} · {entries.length} event
{entries.length === 1 ? "" : "s"}
</summary>
<ol>
Expand Down Expand Up @@ -420,7 +422,7 @@ export function ConversationView({

return (
<section className={className} aria-label="Conversation">
<HistoricalActivity entries={presentation.earlierActivity} />
<ActivityDetails entries={presentation.earlierActivity} />
{presentation.turns.map((turn) => (
<section
className="sa-conversation-turn"
Expand Down Expand Up @@ -455,7 +457,7 @@ export function ConversationView({
<div className="sa-conversation-user">{entry.value.content}</div>
</MessageFrame>
))}
<HistoricalActivity entries={turn.historicalActivities} />
<ActivityDetails entries={turn.historicalActivities} />
<WorkLog turn={turn} renderToolCall={renderToolCall} />
{turn.questions.map((question) => (
<div key={question.key}>
Expand All @@ -464,6 +466,10 @@ export function ConversationView({
)}
</div>
))}
<ActivityDetails
entries={turn.liveActivities}
label="Current activity"
/>
</section>
))}
{assistant?.value ? (
Expand Down
69 changes: 69 additions & 0 deletions web/packages/superagent-ui/src/conversationPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
68 changes: 66 additions & 2 deletions web/packages/superagent-ui/src/conversationPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface ConversationTurn {
operations: ConversationWorkItem[];
questions: ConversationQuestionItem[];
activities: TimelineActivityEntry[];
liveActivities: TimelineActivityEntry[];
historicalActivities: TimelineActivityEntry[];
durationMs: number | null;
workDurationMs: number | null;
Expand All @@ -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",
Expand Down Expand Up @@ -202,6 +210,7 @@ function groupDurableTurns(messages: readonly TimelineSequencedMessage[]) {
operations: [],
questions: [],
activities: [],
liveActivities: [],
historicalActivities: [],
durationMs: null,
workDurationMs: null,
Expand All @@ -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<string, TimelineSequencedMessage>();
const callToTurn = new Map<string, ConversationTurn>();
Expand Down Expand Up @@ -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 (
Expand All @@ -293,6 +313,7 @@ export function buildConversationPresentation(
operations: [],
questions: [],
activities: [],
liveActivities: [],
historicalActivities: [],
durationMs: null,
workDurationMs: null,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down