1458 lines
64 KiB
TypeScript
1458 lines
64 KiB
TypeScript
/**
|
||
* OmniMessage stream → render view-model reducer. A
|
||
* pure logic module, no React dependency: takes in an ordered sequence of
|
||
* OmniMessage (history's complete messages + real-time partial/complete/
|
||
* event), and produces a `ChatItem[]` view model (updated in place, with the caller triggering the re-render).
|
||
*
|
||
* Key points:
|
||
* - Fragment tracking: partial_text / partial_thinking are tracked by "the
|
||
* currently open fragment"; partial_tool_call / partial_tool_call_output
|
||
* are attributed to a tool card by tool_call_id. start opens it, delta
|
||
* accumulates, stop closes it; the subsequent complete message
|
||
* **replaces** the fragment's content (guaranteeing consistency;
|
||
* with no open fragment — mid-stream join — it's appended directly); an
|
||
* orphan delta (no start seen) is ignored, converging once the complete message arrives.
|
||
* - origin routing: messages carrying an origin go into a nested child
|
||
* model (surfaced in the UI as a subagent chip + the subagents panel);
|
||
* on a sub-session's first message it binds to "the most recent allowed
|
||
* (decision=allow) and not-yet-complete run_subagent tool card that
|
||
* hasn't been bound to an origin yet", falling back to a standalone
|
||
* SubagentItem if none is found; inside the nested model, the same
|
||
* reducer recurses (with the first origin hop stripped). Sub-session
|
||
* token_usage counts toward this level's stats (same convention as the CLI).
|
||
* - Events: approval_decision annotates the corresponding tool card
|
||
* (labeled "manual" if clicked on this end, "automatic" otherwise);
|
||
* abort → an interruption marker item; request_end ending in
|
||
* timeout/malformed → a retry-hint item (the engine discards that
|
||
* attempt and resends the original input; the next request_begin marks the hint as resent, and an
|
||
* arriving abort marks it as retries exhausted); other request_begin/end
|
||
* events aren't rendered (Request duration is covered by Trace
|
||
* performance analysis); compaction_begin/end → a banner item;
|
||
* token_usage → fed into stats (task-stats.ts).
|
||
* - Compaction-internal messages (history rebuild): model_msg within a
|
||
* compaction_begin↔end range (the compaction prompt and summary output)
|
||
* are never rendered and never counted toward Task segmentation — aligned
|
||
* with the live stream (which only pushes the event pair + token_usage);
|
||
* user text prefixed with `[context_summary]` is a compaction-summary
|
||
* injection, treated as internal input (not rendered, doesn't start a new Task).
|
||
* - Task segmentation: a complete text/image message on the main
|
||
* session's user side starts a new Task; a Task ends when the live
|
||
* stream receives task_state:idle (notifyTaskIdle), or — during history
|
||
* rebuild — when the next Task starts / the stream ends
|
||
* (finalizeHistory). Live finalization takes the duration as the larger
|
||
* of the local-clock delta and the message-timestamp span (the local
|
||
* clock only covers the time since a mid-stream join). A stats row is only added if token_usage occurred during the Task.
|
||
* - Overlap-dedup helpers: buildDedupIndex/isDuplicate
|
||
* judge duplicates by exact match of the envelope JSON; when a complete
|
||
* message hits the dedup check, discardFragmentFor also discards the corresponding in-flight fragment.
|
||
* Docs: /docs/omni-message § "The streaming discipline".
|
||
*/
|
||
import {
|
||
isEventMessage,
|
||
isPartialPayload,
|
||
parseUserSteeringText,
|
||
} from "@prismshadow/penguin-core/omnimessage";
|
||
import type {
|
||
ApprovalDecision,
|
||
CompactionMode,
|
||
CompactionReason,
|
||
CompleteModelPayload,
|
||
EventPayload,
|
||
OmniMessage,
|
||
PartialModelPayload,
|
||
SessionMetaPayload,
|
||
StopReason,
|
||
TokenUsagePayload,
|
||
} from "@prismshadow/penguin-core/omnimessage";
|
||
import {
|
||
addLlmDuration,
|
||
beginCompaction,
|
||
commitPendingCompaction,
|
||
createTaskStatsTracker,
|
||
endCompaction,
|
||
endTask,
|
||
resetTaskCounters,
|
||
trackMainUsage,
|
||
trackSubagentUsage,
|
||
} from "./task-stats";
|
||
import type { TaskStats, TaskStatsTracker } from "./task-stats";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// View model types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Source of an approval decision: clicked on this end (manual) / other (automatic judgment or submitted by another end). */
|
||
export type DecisionSource = "manual" | "remote";
|
||
|
||
export interface UserTextItem {
|
||
kind: "user_text";
|
||
id: number;
|
||
text: string;
|
||
/** Message timestamp (milliseconds): shown on footer hover. History and real time share the same source — this message's own timestamp. */
|
||
atMs?: number;
|
||
}
|
||
|
||
/**
|
||
* Mid-run steering: a `[user_steering]`-wrapped user text delivered between turns
|
||
* (see core `Session.steer`). Rendered as a compact user-styled chip **inside** the running
|
||
* Task's flow — it never starts a new Task (`text` is the inner message, marker stripped).
|
||
*/
|
||
export interface UserSteeringItem {
|
||
kind: "user_steering";
|
||
id: number;
|
||
text: string;
|
||
/** Message timestamp (milliseconds): shown on footer hover. */
|
||
atMs?: number;
|
||
}
|
||
|
||
export interface UserImageItem {
|
||
kind: "user_image";
|
||
id: number;
|
||
imageUrl: string;
|
||
/** Message timestamp (milliseconds): shown on footer hover. */
|
||
atMs?: number;
|
||
}
|
||
|
||
export interface AssistantTextItem {
|
||
kind: "assistant_text";
|
||
id: number;
|
||
text: string;
|
||
/** The streamed fragment is still accumulating. */
|
||
streaming: boolean;
|
||
stopReason?: StopReason;
|
||
/**
|
||
* Message timestamp (milliseconds): shown on footer hover. During
|
||
* streaming it's a placeholder using the partial start's timestamp;
|
||
* once the complete message arrives, it switches to that message's own
|
||
* timestamp — the same convention as Trace (which records the **completion** time).
|
||
*/
|
||
atMs?: number;
|
||
}
|
||
|
||
export interface ThinkingItem {
|
||
kind: "thinking";
|
||
id: number;
|
||
thinking: string;
|
||
streaming: boolean;
|
||
stopReason?: StopReason;
|
||
/** Start timestamp in milliseconds (the partial start's message time; approximated by the previous message's time when history has no fragments). */
|
||
startedAtMs?: number;
|
||
/** Thinking duration (settled when the complete message arrives: message time - start time). */
|
||
durationMs?: number;
|
||
}
|
||
|
||
export interface ToolCallItem {
|
||
kind: "tool_call";
|
||
id: number;
|
||
toolCallId: string;
|
||
name: string;
|
||
/** Tool argument JSON (accumulated via streamed deltas, replaced once the complete message arrives). */
|
||
argumentsText: string;
|
||
callStreaming: boolean;
|
||
/** A complete tool_call message has been received (all streamed copies after this are ignored). */
|
||
callComplete: boolean;
|
||
callStopReason?: StopReason;
|
||
/** Tool output (appended via streaming, replaced once the complete message arrives; truncation/timeout/interruption markers are already in the text). */
|
||
output: string;
|
||
/** Images carried by the tool output (an array of data URLs; a streamed delta carries the whole array at once, and the complete message converges it again). */
|
||
images?: string[];
|
||
outputStreaming: boolean;
|
||
outputComplete: boolean;
|
||
outputStopReason?: StopReason;
|
||
/** Approval decision (annotated by the approval_decision event). */
|
||
decision?: ApprovalDecision;
|
||
decisionSource?: DecisionSource;
|
||
/** run_subagent: the bound sub-session stream (nested model). */
|
||
subagent?: StreamModel;
|
||
subagentSessionId?: string;
|
||
/** Tool execution start (the message time when the tool_call closed; same convention as Trace analysis). */
|
||
callStartedAtMs?: number;
|
||
/** Approval-granted moment (the approval_decision message time): execution timing starts from here, deducting the approval wait. */
|
||
approvalAtMs?: number;
|
||
/** This card's approval wait has already been counted toward its owning Request (see noteApprovalWait): whichever of the two timestamps arrives later triggers it, guarding against double-counting. */
|
||
approvalWaitCounted?: boolean;
|
||
/**
|
||
* Argument generation start (the partial_tool_call start's message time):
|
||
* the rolling timing baseline during streamed argument generation.
|
||
* Approximated by the previous message's time when history rebuild has no fragments (same convention as thinking).
|
||
*/
|
||
argStartedAtMs?: number;
|
||
/** Total tool duration (settled when the tool_call_output complete message arrives) = the argument-generation segment + the execution segment, excluding the approval wait (see settleToolDuration). */
|
||
durationMs?: number;
|
||
}
|
||
|
||
/** A standalone sub-session card for when no run_subagent tool card can be bound. */
|
||
export interface SubagentItem {
|
||
kind: "subagent";
|
||
id: number;
|
||
sessionId: string;
|
||
model: StreamModel;
|
||
}
|
||
|
||
export interface AbortItem {
|
||
kind: "abort";
|
||
id: number;
|
||
reason?: string;
|
||
}
|
||
|
||
/** An LLM Request ending in timeout/malformed → the engine retries carrying the content already produced. */
|
||
export interface ReconnectItem {
|
||
kind: "reconnect";
|
||
id: number;
|
||
/** Trigger reason: timeout (timed out / disconnected) or malformed (an incomplete or unparseable response). */
|
||
status: "timeout" | "malformed";
|
||
/** Which retry attempt this is (increments on consecutive failures within the same round; resets to 1 after a request finishes normally). */
|
||
attempt: number;
|
||
/** The retry request has been sent (set true by the next request_begin). */
|
||
retrying: boolean;
|
||
/** Retries exhausted (set true when an abort event arrives; the subsequent interruption marker item gives the reason). */
|
||
gaveUp?: boolean;
|
||
/**
|
||
* The engine's planned wait before the next attempt (request_end.retry_in_ms; absent
|
||
* when the event carried none — old Traces, or a final failure). Waits can reach the
|
||
* 30s backoff ceiling, so the view renders a live countdown for the waiting state when
|
||
* this is ≥2s (see ReconnectLine).
|
||
*/
|
||
plannedDelayMs?: number;
|
||
/**
|
||
* CLIENT-clock arrival time of the request_end (the countdown anchor — client-local, so
|
||
* server clock skew cannot bend the ticker). On history replay the following
|
||
* request_begin/abort arrives immediately and flips retrying/gaveUp, so a replayed item
|
||
* never stays in the waiting state to tick.
|
||
*/
|
||
arrivedAtMs?: number;
|
||
}
|
||
|
||
export interface CompactionItem {
|
||
kind: "compaction";
|
||
id: number;
|
||
reason: CompactionReason;
|
||
mode: CompactionMode;
|
||
/** True between begin and end (renders a "compaction in progress" banner). */
|
||
running: boolean;
|
||
status?: StopReason;
|
||
}
|
||
|
||
export interface TaskStatsItem {
|
||
kind: "task_stats";
|
||
id: number;
|
||
/**
|
||
* This Task's stats; `null` = no token_usage occurred this round (e.g. the
|
||
* reply was interrupted mid-way), so there's nothing to show. This item
|
||
* is still produced in that case — it also serves as that reply's
|
||
* **footer** (timestamp + copy); not producing it would leave an interrupted reply without a timestamp or copy button.
|
||
*/
|
||
stats: TaskStats | null;
|
||
/** This Task's assistant text (the copy button's target); an empty string when there's no text. */
|
||
assistantText: string;
|
||
/**
|
||
* Timestamp (milliseconds) of this Task's last assistant text. The stats
|
||
* row sits right below the AI reply and itself doubles as that reply's
|
||
* footer — the timestamp and copy both belong to it, and the assistant
|
||
* message is never rendered with its own separate footer (otherwise two copy buttons would appear in the same spot).
|
||
*/
|
||
atMs?: number;
|
||
}
|
||
|
||
export type ChatItem =
|
||
| UserTextItem
|
||
| UserSteeringItem
|
||
| UserImageItem
|
||
| AssistantTextItem
|
||
| ThinkingItem
|
||
| ToolCallItem
|
||
| SubagentItem
|
||
| AbortItem
|
||
| ReconnectItem
|
||
| CompactionItem
|
||
| TaskStatsItem;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Identity of a nested child session, captured from its own session_meta (a child session's
|
||
* DTO isn't loaded by the chat page, so this is the panel's only live source for "which agent
|
||
* runs this child"). Main-session models never fill it — their identity comes from the Session DTO.
|
||
*/
|
||
export interface NestedSessionMeta {
|
||
/** Agent id parsed from the `agent_state` path (its parent directory name); null when unparseable. */
|
||
agentId: string | null;
|
||
provider: string;
|
||
modelId: string;
|
||
/** Session origin as recorded by core (subagent / schedule); absent = user-created. */
|
||
source?: "subagent" | "schedule";
|
||
}
|
||
|
||
export interface StreamModel {
|
||
items: ChatItem[];
|
||
/** A nested sub-session model (produces no stats row; its stats count toward the parent). */
|
||
nested: boolean;
|
||
/** Child-session identity from its session_meta (nested models only; null until it arrives). */
|
||
meta: NestedSessionMeta | null;
|
||
/**
|
||
* Elapsed-time stamps for the subagents panel's topology nodes (nested models only — the main
|
||
* session's timing is covered by task stats). Stamped in routeNested: the `firstSeen` pair when
|
||
* the nested model is created, the `lastActivity` pair on every message routed into its subtree
|
||
* (cheap assignments). Two clocks are kept because neither works alone:
|
||
* - Local wall clock (`firstSeenLocalMs` / `lastActivityLocalMs`): when this client saw the
|
||
* child appear / last act. Faithful only while watching live — a history replay sets all of
|
||
* them within one synchronous load, so every replayed span collapses to ~0 at load time.
|
||
* - Message timestamps (`firstTsMs` / `lastActivityTsMs`): the same two moments in SERVER
|
||
* time, recorded identically during live streaming and history replay — a reload reproduces
|
||
* the same span. May drift from the local clock (same caveat as LiveDuration's sinceMs).
|
||
* Topology extraction therefore derives a done node's duration from the timestamp pair (correct
|
||
* in both live and reloaded views) and ticks a running node from firstTsMs, falling back to the
|
||
* local pair only when timestamps were unparseable (see agent-topology.ts).
|
||
*/
|
||
firstSeenLocalMs?: number;
|
||
lastActivityLocalMs?: number;
|
||
firstTsMs?: number;
|
||
lastActivityTsMs?: number;
|
||
stats: TaskStatsTracker;
|
||
/** The currently open text/thinking fragment (opened by start, closed by stop). */
|
||
openText: AssistantTextItem | null;
|
||
openThinking: ThinkingItem | null;
|
||
/** A fragment that has stopped and is waiting to be replaced by the complete message. */
|
||
pendingText: AssistantTextItem | null;
|
||
pendingThinking: ThinkingItem | null;
|
||
/** tool_call_id → tool card (shared by both fragment attribution and complete-message replacement). */
|
||
toolCards: Map<string, ToolCallItem>;
|
||
/** Direct child Session id → nested model. */
|
||
subagents: Map<string, StreamModel>;
|
||
/** toolCallIds whose approval was clicked on this end (shares the reference with nested models, labeled "manual"). */
|
||
localDecisions: Set<string>;
|
||
/** Approval decisions that arrived before their tool card (backfilled when the card is created). */
|
||
pendingDecisions: Map<string, ApprovalDecision>;
|
||
/** Approval timestamps that arrived before their tool card (backfilled into approvalAtMs when the card is created, used to deduct the approval duration). */
|
||
pendingDecisionTs: Map<string, number>;
|
||
/** Timestamp of the most recent message (used to approximate the start time when history's thinking has no fragments). */
|
||
lastTsMs: number;
|
||
/**
|
||
* The millisecond time of the main session's currently unclosed Request's
|
||
* request_begin (used for output TPS timing): when request_end arrives, it
|
||
* pairs with this to compute the wall-clock duration added to this Task's
|
||
* LLM time; compaction requests aren't timed; a later begin overrides an unclosed one.
|
||
*/
|
||
openRequestBeginMs: number | null;
|
||
/**
|
||
* Total human approval wait time (milliseconds) within the currently
|
||
* unclosed Request: deducted from the wall-clock duration at request_end,
|
||
* so only the time the LLM is actually generating counts toward the
|
||
* output TPS denominator. Core does `await approve(tc)` inside the
|
||
* streaming loop — if approval doesn't return, the next chunk isn't
|
||
* consumed and request_end isn't emitted either, so the whole human wait
|
||
* sits sandwiched between request_begin and request_end; without
|
||
* deducting it, "5s of generation + 55s of approval wait" would render
|
||
* 100 tok/s as 8 tok/s. Tool **execution** isn't included here (core
|
||
* dispatches it via `void executeOne`, which doesn't block the streaming loop — execution happens between two Requests).
|
||
*/
|
||
openApprovalWaitMs: number;
|
||
/** Consecutive reconnect-failure count (incremented when request_end is timeout/malformed, reset to zero on any other terminal status). */
|
||
reconnectRun: number;
|
||
/**
|
||
* Timestamp (ms) of the most recent auth failure: a main-session `request_end` with
|
||
* status "auth" arrived on THIS model (a subagent's request events route to the nested
|
||
* model and never mark the parent). Only the model REFERENCE is fixed at Session
|
||
* creation — credentials are read from the current Project config on load — so this is
|
||
* recoverable: the composer disables itself and points at the Models page. Cleared by a
|
||
* later completed main-session request_end (live and history replay take the same path,
|
||
* so a Trace with an auth failure followed by a completed request does not resurrect the
|
||
* state on reload), by a `credentials_updated` server event, or by an explicit user
|
||
* retry/dismiss; a timestamp (not a boolean) so the composer gate can compare it against
|
||
* the Project's credentials-updated time after a reload (see isModelAuthDead). Re-arms
|
||
* on the next auth failure.
|
||
*/
|
||
lastAuthFailureMs: number | null;
|
||
/** Task segmentation state. */
|
||
taskOpen: boolean;
|
||
taskStartLocalMs: number;
|
||
taskFirstTsMs: number;
|
||
/**
|
||
* The latest timestamp seen among this round's messages, used only as a
|
||
* **fallback for the round's end**: only used for a degenerate round that
|
||
* has no request_end at all (interrupted before its first Request even
|
||
* ran). The normal round-end is taken from taskLastReqEndMs.
|
||
*/
|
||
taskLastTsMs: number;
|
||
/**
|
||
* The timestamp of this round's last **non-compaction** request_end — this
|
||
* is the true round end, and this round's duration = it − the first
|
||
* message. A round's real work is done once its last Request finishes:
|
||
* - Automatic compaction **mid-round** (the engine keeps running with a
|
||
* carry-over after compacting, so a normal Request follows the
|
||
* compaction) sits **within** the span and is naturally counted into
|
||
* the round's duration — which is correct, since compaction did occupy this round's wall-clock time;
|
||
* - Compaction **after the round ends** (finalization's automatic
|
||
* compaction / manual /compact), the next round's injected
|
||
* `[context_summary]`, and the session_meta rewritten after a file
|
||
* rotation all come **after** it, and are naturally excluded from the round.
|
||
* So no compaction wall-clock addition/subtraction is needed at all —
|
||
* just take the span directly (history rebuild and live share the same
|
||
* convention, consistent before and after a refresh).
|
||
* null = this round has no request_end yet (a degenerate round, falls back to taskLastTsMs).
|
||
*/
|
||
taskLastReqEndMs: number | null;
|
||
nextItemId: number;
|
||
}
|
||
|
||
function newModel(nested: boolean, localDecisions: Set<string>): StreamModel {
|
||
return {
|
||
items: [],
|
||
nested,
|
||
meta: null,
|
||
stats: createTaskStatsTracker(),
|
||
openText: null,
|
||
openThinking: null,
|
||
pendingText: null,
|
||
pendingThinking: null,
|
||
toolCards: new Map(),
|
||
subagents: new Map(),
|
||
localDecisions,
|
||
pendingDecisions: new Map(),
|
||
pendingDecisionTs: new Map(),
|
||
lastTsMs: 0,
|
||
openRequestBeginMs: null,
|
||
openApprovalWaitMs: 0,
|
||
reconnectRun: 0,
|
||
lastAuthFailureMs: null,
|
||
taskOpen: false,
|
||
taskStartLocalMs: 0,
|
||
taskFirstTsMs: 0,
|
||
taskLastTsMs: 0,
|
||
taskLastReqEndMs: null,
|
||
nextItemId: 1,
|
||
};
|
||
}
|
||
|
||
/** Create the main-session model; localDecisions can inject a shared set (persisting across models when a resync rebuild swaps in a new one). */
|
||
export function createStreamModel(localDecisions: Set<string> = new Set()): StreamModel {
|
||
return newModel(false, localDecisions);
|
||
}
|
||
|
||
/**
|
||
* Composer auth-dead gate: an auth failure is on record AND the Project's credentials have
|
||
* not been updated since. `credentialsUpdatedMs` is the models response's `updatedAt`
|
||
* (null = unknown / not loaded — nothing proves the credential changed, so the notice
|
||
* stays). The time comparison is what keeps a reload honest: after a key fix the Trace
|
||
* still ends with the auth-failed request, but that failure predates the credential update, so the
|
||
* composer stays alive; a wrong replacement key re-arms naturally because its own auth
|
||
* abort is newer than the update.
|
||
*/
|
||
export function isModelAuthDead(
|
||
lastAuthFailureMs: number | null,
|
||
credentialsUpdatedMs: number | null,
|
||
): boolean {
|
||
if (lastAuthFailureMs === null) return false;
|
||
return credentialsUpdatedMs === null || lastAuthFailureMs > credentialsUpdatedMs;
|
||
}
|
||
|
||
function nextId(model: StreamModel): number {
|
||
return model.nextItemId++;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Feed in one OmniMessage in order (history or live); nowMs is used for the Task's live timing (injectable for tests). */
|
||
export function pushMessage(
|
||
model: StreamModel,
|
||
msg: OmniMessage,
|
||
nowMs: number = Date.now(),
|
||
): void {
|
||
if (msg.origin && msg.origin.length > 0) {
|
||
routeNested(model, msg, nowMs);
|
||
return;
|
||
}
|
||
if (msg.type === "model_msg") {
|
||
// Internal messages within a compaction range (between begin and end)
|
||
// (the compaction prompt, summary output): never rendered, never
|
||
// counted toward Task segmentation — aligned with the live stream
|
||
// (which only pushes the event pair and token_usage). Only encountered during history rebuild.
|
||
if (model.stats.compactionActive) {
|
||
touchTask(model, msg.timestamp);
|
||
advanceLastTs(model, msg.timestamp);
|
||
return;
|
||
}
|
||
if (isPartialPayload(msg.payload)) {
|
||
touchTask(model, msg.timestamp);
|
||
handlePartial(model, msg.payload, tsOf(msg.timestamp));
|
||
// lastTsMs only advances from complete messages/events: an orphan
|
||
// delta during a mid-stream join shouldn't push "the previous
|
||
// message's time" up to just before a complete thinking message, or the approximated historical duration would collapse to ~0ms.
|
||
return;
|
||
}
|
||
handleComplete(model, msg.payload as CompleteModelPayload, msg.timestamp, nowMs);
|
||
advanceLastTs(model, msg.timestamp);
|
||
return;
|
||
}
|
||
if (isEventMessage(msg)) {
|
||
touchTask(model, msg.timestamp);
|
||
handleEvent(model, msg.payload as EventPayload, tsOf(msg.timestamp), nowMs);
|
||
advanceLastTs(model, msg.timestamp);
|
||
return;
|
||
}
|
||
// session_meta: never rendered as an item. For the main session it carries nothing the view
|
||
// model needs (identity/config are all surfaced through the Session DTO). For a NESTED child
|
||
// session no DTO is loaded here, so capture the identity the subagents panel needs (which
|
||
// agent runs this child, on which model) — a rewritten session_meta (file rotation) simply
|
||
// overwrites with the same values.
|
||
if (msg.type === "session_meta" && model.nested) {
|
||
const p = msg.payload as SessionMetaPayload;
|
||
const meta: NestedSessionMeta = {
|
||
agentId: agentIdFromStatePath(p.agent_state),
|
||
provider: p.provider,
|
||
modelId: p.model_id,
|
||
};
|
||
if (p.source !== undefined) meta.source = p.source;
|
||
model.meta = meta;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Agent id from a session_meta `agent_state` path: the path is
|
||
* `<root>/<projectId>/agents/<agentId>/agent_state`, so the agent id is the parent directory
|
||
* name. Returns null when the path has no parent segment (defensive — core always writes the full path).
|
||
*/
|
||
export function agentIdFromStatePath(agentStatePath: string): string | null {
|
||
const segments = agentStatePath.split(/[\\/]/).filter((s) => s.length > 0);
|
||
return segments.length >= 2 ? segments[segments.length - 2]! : null;
|
||
}
|
||
|
||
/**
|
||
* Whether any pending approval sits at or below the given origin chain (prefix match on
|
||
* approvalKey): `chain` is the ancestor chain ending with the subtree root's own session id.
|
||
* Drives the subagent chip's amber dot and the toolbar badge — a nested approval must stay
|
||
* discoverable even though the child conversation lives in the side panel.
|
||
*/
|
||
export function hasPendingWithinOrigin(
|
||
pendingKeys: Iterable<string>,
|
||
chain: readonly string[],
|
||
): boolean {
|
||
const prefix = chain.join("/");
|
||
for (const key of pendingKeys) {
|
||
// The approvalKey delimiters are load-bearing here: a key is `origin.join("/") + " " +
|
||
// toolCallId`, so requiring the chain to be followed by a space (an approval on the subtree
|
||
// root itself) or a slash (one strictly below it) is what keeps a sibling whose id merely
|
||
// extends this chain ("c1" vs "c1x") from matching, and keeps a main-session key (leading
|
||
// space) from matching any chain. Refactoring approvalKey to another separator would
|
||
// silently break this predicate — change the two together.
|
||
if (key.startsWith(`${prefix} `) || key.startsWith(`${prefix}/`)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** ISO timestamp → milliseconds (returns undefined if invalid). */
|
||
function tsOf(timestamp: string): number | undefined {
|
||
const ms = Date.parse(timestamp);
|
||
return Number.isFinite(ms) ? ms : undefined;
|
||
}
|
||
|
||
function advanceLastTs(model: StreamModel, timestamp: string): void {
|
||
const ms = Date.parse(timestamp);
|
||
if (Number.isFinite(ms)) model.lastTsMs = ms;
|
||
}
|
||
|
||
export function pushMessages(
|
||
model: StreamModel,
|
||
messages: OmniMessage[],
|
||
nowMs: number = Date.now(),
|
||
): void {
|
||
for (const msg of messages) pushMessage(model, msg, nowMs);
|
||
}
|
||
|
||
/** The live stream received task_state:idle: finalize the current Task using the local clock. */
|
||
export function notifyTaskIdle(model: StreamModel, nowMs: number = Date.now()): void {
|
||
finalizeOpenTask(model, "live", nowMs);
|
||
}
|
||
|
||
/** History rebuild is complete (end of stream): finalize the last Task using message timestamps. */
|
||
export function finalizeHistory(model: StreamModel): void {
|
||
finalizeOpenTask(model, "history");
|
||
}
|
||
|
||
/** Register an approval clicked on this end (so the subsequent approval_decision event is labeled "manual"). */
|
||
export function registerLocalDecision(model: StreamModel, toolCallId: string): void {
|
||
model.localDecisions.add(toolCallId);
|
||
}
|
||
|
||
/**
|
||
* Pending-approvals table key: `origin.join("/") + " " + toolCallId` (empty
|
||
* origin for the main session). A parent/child session's tool_call_id can
|
||
* collide, so the origin chain must be included to distinguish them and
|
||
* avoid lighting up the approval button on the wrong tool card.
|
||
* The "/" and " " separators are relied on by hasPendingWithinOrigin's prefix
|
||
* matching — keep the two in sync if this format ever changes.
|
||
*/
|
||
export function approvalKey(origin: readonly string[] | undefined, toolCallId: string): string {
|
||
return `${origin?.join("/") ?? ""} ${toolCallId}`;
|
||
}
|
||
|
||
/** Locate a tool card in a nested model (at any depth) by its origin chain; returns null if there's no matching card. */
|
||
export function findToolCard(
|
||
model: StreamModel,
|
||
origin: readonly string[] | undefined,
|
||
toolCallId: string,
|
||
): ToolCallItem | null {
|
||
let cur: StreamModel | undefined = model;
|
||
for (const hop of origin ?? []) {
|
||
cur = cur.subagents.get(hop);
|
||
if (!cur) return null;
|
||
}
|
||
return cur.toolCards.get(toolCallId) ?? null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task segmentation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Advance this round's "latest timestamp seen among its messages" — used
|
||
* only as a **fallback for the round's end** (see taskLastReqEndMs). The
|
||
* normal round-end is set by request_end; this only guarantees a usable
|
||
* upper bound for a degenerate round with no request_end at all
|
||
* (interrupted before its first Request even ran). Compaction forms its
|
||
* own round, and messages within its range don't belong to this round, so this isn't advanced for them.
|
||
*/
|
||
function touchTask(model: StreamModel, timestamp: string): void {
|
||
if (!model.taskOpen) return;
|
||
if (model.stats.compactionActive) return;
|
||
const ts = Date.parse(timestamp);
|
||
if (!Number.isFinite(ts) || ts <= model.taskLastTsMs) return;
|
||
model.taskLastTsMs = ts;
|
||
}
|
||
|
||
function startTask(model: StreamModel, timestamp: string, nowMs: number): void {
|
||
// The previous Task is finalized by "the next Task starting" (the history-rebuild convention).
|
||
finalizeOpenTask(model, "history");
|
||
// Finalize any retry state left over from the previous Task: when the
|
||
// server dies during a backoff window, the Trace's tail is
|
||
// request_end(timeout) with no abort, and history rebuild would leave a
|
||
// dangling "retrying…" — the new Task's first request_begin isn't its
|
||
// retry, so mark it gaveUp and reset the consecutive-failure count (the new Task's failures count from 1 again).
|
||
const waiting = findLastWaitingReconnect(model);
|
||
if (waiting) waiting.gaveUp = true;
|
||
model.reconnectRun = 0;
|
||
// Any unclosed Request start / approval wait left over from the previous Task isn't carried into this Task's LLM timing.
|
||
model.openRequestBeginMs = null;
|
||
model.openApprovalWaitMs = 0;
|
||
model.taskOpen = true;
|
||
model.taskStartLocalMs = nowMs;
|
||
const ts = Date.parse(timestamp);
|
||
model.taskFirstTsMs = Number.isFinite(ts) ? ts : nowMs;
|
||
model.taskLastTsMs = model.taskFirstTsMs;
|
||
model.taskLastReqEndMs = null;
|
||
// Usage outside this Task's boundary (e.g. a manual compaction) shouldn't be mistakenly counted into this Task's delta.
|
||
resetTaskCounters(model.stats);
|
||
}
|
||
|
||
function finalizeOpenTask(model: StreamModel, mode: "history" | "live", nowMs?: number): void {
|
||
if (!model.taskOpen) return;
|
||
model.taskOpen = false;
|
||
// No more tool output will arrive once a Task is finalized: close cards still "executing" and stop their LiveDuration.
|
||
closeExecutingToolCards(model);
|
||
// This round's duration = this round's last non-compaction request_end −
|
||
// the first message. The round's end is exactly the last Request's
|
||
// finish: compaction **mid-round** sits within the span and is naturally
|
||
// counted in (which is correct — it did occupy this round's wall-clock
|
||
// time), while compaction **after the round ends** sits outside the span
|
||
// and is naturally excluded — no compaction wall-clock addition/subtraction
|
||
// is needed at all, and history rebuild and live share the same
|
||
// convention, consistent before and after a refresh (see taskLastReqEndMs).
|
||
const endMs = model.taskLastReqEndMs ?? model.taskLastTsMs;
|
||
const tsElapsed = Math.max(0, endMs - model.taskFirstTsMs);
|
||
// Only a degenerate round (no request_end at all for the whole round,
|
||
// e.g. interrupted before its first Request even ran) falls back to the
|
||
// local clock: during a mid-stream join / resync rebuild, the local clock
|
||
// only covers the time since joining, so it's compared against the
|
||
// message span and the larger is taken to avoid underestimating. When
|
||
// there is a request_end, it's the accurate round-end and is used
|
||
// directly (not compared against the local clock, to avoid folding in noise like idle-detection latency).
|
||
let elapsed: number;
|
||
if (model.taskLastReqEndMs !== null) {
|
||
elapsed = tsElapsed;
|
||
} else if (mode === "live") {
|
||
elapsed = Math.max((nowMs ?? Date.now()) - model.taskStartLocalMs, tsElapsed);
|
||
} else {
|
||
elapsed = tsElapsed;
|
||
}
|
||
const stats = endTask(model.stats, elapsed);
|
||
if (model.nested) return;
|
||
const reply = collectTaskAssistant(model);
|
||
// No token_usage (the reply was interrupted mid-way) → there are no stats
|
||
// to show, but as long as this round produced any text, there still needs
|
||
// to be a footer: the timestamp and copy are both rendered by the stats
|
||
// row, so not producing it here would leave that reply with no footer at
|
||
// all. Only skip when both are absent — then there's truly nothing to do.
|
||
if (stats === null && reply.text === "") return;
|
||
const statsItem: TaskStatsItem = {
|
||
kind: "task_stats",
|
||
id: nextId(model),
|
||
stats,
|
||
assistantText: reply.text,
|
||
...(reply.atMs !== undefined ? { atMs: reply.atMs } : {}),
|
||
};
|
||
// The stats row is inserted **before this trailing run of compaction
|
||
// banners**: an automatic compaction triggered while finalizing a round
|
||
// would otherwise sandwich its banner between the reply and the stats row
|
||
// (items: assistant_text → compaction → task_stats), leaving the stats
|
||
// row underneath the banner, reading as if it were "the compaction's
|
||
// stats" when it's actually reporting this round's conversation.
|
||
// Compaction is housekeeping outside this round, so it belongs after this round's ledger, not before it.
|
||
let at = model.items.length;
|
||
while (at > 0 && model.items[at - 1]!.kind === "compaction") at--;
|
||
model.items.splice(at, 0, statsItem);
|
||
}
|
||
|
||
/**
|
||
* Collect this Task's assistant text (walking backward from the end until
|
||
* the previous task_stats, concatenating assistant_text), and give the
|
||
* timestamp of the **last** assistant text item — the stats row is this
|
||
* round's reply's footer, and this is the timestamp it shows.
|
||
*/
|
||
function collectTaskAssistant(model: StreamModel): { text: string; atMs?: number } {
|
||
const parts: string[] = [];
|
||
let atMs: number | undefined;
|
||
for (let i = model.items.length - 1; i >= 0; i--) {
|
||
const it = model.items[i]!;
|
||
if (it.kind === "task_stats") break;
|
||
if (it.kind === "assistant_text" && it.text.trim()) {
|
||
parts.push(it.text);
|
||
if (atMs === undefined) atMs = it.atMs; // walking backward: the first hit is the last one
|
||
}
|
||
}
|
||
return { text: parts.reverse().join("\n\n"), ...(atMs !== undefined ? { atMs } : {}) };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Streamed fragments
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handlePartial(model: StreamModel, p: PartialModelPayload, tsMs?: number): void {
|
||
switch (p.type) {
|
||
case "partial_text": {
|
||
if (p.event_type === "start") {
|
||
// start reopens the fragment; a stale pending that never got replaced keeps its streamed content and stops waiting to be replaced.
|
||
model.pendingText = null;
|
||
const item: AssistantTextItem = {
|
||
kind: "assistant_text",
|
||
id: nextId(model),
|
||
text: p.text ?? "",
|
||
streaming: true,
|
||
...(tsMs !== undefined ? { atMs: tsMs } : {}),
|
||
};
|
||
model.openText = item;
|
||
model.items.push(item);
|
||
return;
|
||
}
|
||
const open = model.openText;
|
||
if (!open) return; // orphan delta/stop: ignored, converging once the complete message arrives
|
||
if (p.text) open.text += p.text;
|
||
if (p.event_type === "stop") {
|
||
open.streaming = false;
|
||
if (p.stop_reason !== undefined) open.stopReason = p.stop_reason;
|
||
model.pendingText = open;
|
||
model.openText = null;
|
||
}
|
||
return;
|
||
}
|
||
case "partial_thinking": {
|
||
if (p.event_type === "start") {
|
||
model.pendingThinking = null;
|
||
const item: ThinkingItem = {
|
||
kind: "thinking",
|
||
id: nextId(model),
|
||
thinking: p.thinking ?? "",
|
||
streaming: true,
|
||
};
|
||
if (tsMs !== undefined) item.startedAtMs = tsMs;
|
||
model.openThinking = item;
|
||
model.items.push(item);
|
||
return;
|
||
}
|
||
const open = model.openThinking;
|
||
if (!open) return; // orphan, ignored
|
||
if (p.thinking) open.thinking += p.thinking;
|
||
if (p.event_type === "stop") {
|
||
open.streaming = false;
|
||
if (p.stop_reason !== undefined) open.stopReason = p.stop_reason;
|
||
settleThinkingDuration(open, tsMs);
|
||
model.pendingThinking = open;
|
||
model.openThinking = null;
|
||
}
|
||
return;
|
||
}
|
||
case "partial_tool_call": {
|
||
const card = model.toolCards.get(p.tool_call_id);
|
||
// The complete message already arrived (history / dedup hit): the whole streamed copy is ignored.
|
||
if (card?.callComplete) return;
|
||
if (p.event_type === "start") {
|
||
if (card) {
|
||
// Duplicate start (out-of-order): reset the argument buffer.
|
||
card.name = p.name || card.name;
|
||
card.argumentsText = p.arguments ?? "";
|
||
card.callStreaming = true;
|
||
if (tsMs !== undefined && card.argStartedAtMs === undefined) card.argStartedAtMs = tsMs;
|
||
return;
|
||
}
|
||
const created = createToolCard(model, {
|
||
toolCallId: p.tool_call_id,
|
||
name: p.name,
|
||
argumentsText: p.arguments ?? "",
|
||
callStreaming: true,
|
||
});
|
||
if (tsMs !== undefined) created.argStartedAtMs = tsMs;
|
||
return;
|
||
}
|
||
if (!card) return; // orphan, ignored
|
||
if (p.name && !card.name) card.name = p.name;
|
||
if (p.arguments) card.argumentsText += p.arguments;
|
||
if (p.event_type === "stop") {
|
||
card.callStreaming = false;
|
||
if (p.stop_reason !== undefined) card.callStopReason = p.stop_reason;
|
||
// Execution start = the call's closing timestamp (same convention as Trace analysis: tool_call → tool_call_output).
|
||
if (tsMs !== undefined) card.callStartedAtMs = tsMs;
|
||
}
|
||
return;
|
||
}
|
||
case "partial_tool_call_output": {
|
||
const card = model.toolCards.get(p.tool_call_id);
|
||
// No matching call card (orphan) or output already complete: ignored, converging once the complete message arrives.
|
||
if (!card || card.outputComplete) return;
|
||
if (p.event_type === "start") {
|
||
card.outputStreaming = true;
|
||
if (p.output) card.output += p.output;
|
||
// A live-tail synthetic start (mid-stream join seed) may already carry the image
|
||
// set; same whole-set semantics as the delta branch below.
|
||
if (p.images && p.images.length > 0) card.images = p.images;
|
||
return;
|
||
}
|
||
if (!card.outputStreaming) return; // orphan delta/stop
|
||
if (p.output) card.output += p.output;
|
||
// Image delta: a single delta carries the whole array at once (the complete message converges it again, overwriting with the same value).
|
||
if (p.images && p.images.length > 0) card.images = p.images;
|
||
if (p.event_type === "stop") {
|
||
card.outputStreaming = false;
|
||
if (p.stop_reason !== undefined) card.outputStopReason = p.stop_reason;
|
||
settleToolDuration(card, tsMs);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Complete messages
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleComplete(
|
||
model: StreamModel,
|
||
p: CompleteModelPayload,
|
||
timestamp: string,
|
||
nowMs: number,
|
||
): void {
|
||
switch (p.type) {
|
||
case "text": {
|
||
if (p.role === "user") {
|
||
// Compaction-summary injection (`[context_summary]` prefix, an
|
||
// internal input in the new context file): not rendered as a user bubble, doesn't
|
||
// start a new Task. The old `<context_summary>` prefix is still recognized — old
|
||
// Traces containing it are re-rendered through this reducer.
|
||
if (p.text.startsWith("[context_summary]") || p.text.startsWith("<context_summary>")) {
|
||
touchTask(model, timestamp);
|
||
return;
|
||
}
|
||
// Mid-run steering (`[user_steering]`-wrapped user text, delivered between turns):
|
||
// stays inside the running Task — it must NOT start a new Task (same exclusion idea
|
||
// as [context_summary]) — but unlike the summary it IS rendered, as a compact
|
||
// user-styled steering chip in-flow.
|
||
const steering = parseUserSteeringText(p.text);
|
||
if (steering !== null) {
|
||
touchTask(model, timestamp);
|
||
const steerMs = tsOf(timestamp);
|
||
model.items.push({
|
||
kind: "user_steering",
|
||
id: nextId(model),
|
||
text: steering,
|
||
...(steerMs !== undefined ? { atMs: steerMs } : {}),
|
||
});
|
||
return;
|
||
}
|
||
// A complete text message on the main session's user side: starts a new Task.
|
||
startTask(model, timestamp, nowMs);
|
||
const atMs = tsOf(timestamp);
|
||
model.items.push({
|
||
kind: "user_text",
|
||
id: nextId(model),
|
||
text: p.text,
|
||
...(atMs !== undefined ? { atMs } : {}),
|
||
});
|
||
return;
|
||
}
|
||
touchTask(model, timestamp);
|
||
// The complete message usually follows right after a fragment's stop: prefer replacing an already-closed pending fragment, then a still-open one.
|
||
const target = model.pendingText ?? model.openText;
|
||
if (target) {
|
||
// The complete message replaces the fragment's content (this guarantees consistency).
|
||
target.text = p.text;
|
||
target.streaming = false;
|
||
const doneMs = tsOf(timestamp);
|
||
if (doneMs !== undefined) target.atMs = doneMs; // the completion timestamp overrides the start placeholder
|
||
if (p.stop_reason !== undefined) target.stopReason = p.stop_reason;
|
||
if (target === model.openText) model.openText = null;
|
||
model.pendingText = null;
|
||
return;
|
||
}
|
||
// No open fragment (history / mid-stream join): append directly.
|
||
const doneMs = tsOf(timestamp);
|
||
const item: AssistantTextItem = {
|
||
kind: "assistant_text",
|
||
id: nextId(model),
|
||
text: p.text,
|
||
streaming: false,
|
||
...(doneMs !== undefined ? { atMs: doneMs } : {}),
|
||
};
|
||
if (p.stop_reason !== undefined) item.stopReason = p.stop_reason;
|
||
model.items.push(item);
|
||
return;
|
||
}
|
||
case "image_url": {
|
||
startTask(model, timestamp, nowMs);
|
||
const imgMs = tsOf(timestamp);
|
||
model.items.push({
|
||
kind: "user_image",
|
||
id: nextId(model),
|
||
imageUrl: p.image_url,
|
||
...(imgMs !== undefined ? { atMs: imgMs } : {}),
|
||
});
|
||
return;
|
||
}
|
||
case "thinking": {
|
||
touchTask(model, timestamp);
|
||
const tsMs = tsOf(timestamp);
|
||
const target = model.pendingThinking ?? model.openThinking;
|
||
if (target) {
|
||
target.thinking = p.thinking;
|
||
target.streaming = false;
|
||
if (p.stop_reason !== undefined) target.stopReason = p.stop_reason;
|
||
settleThinkingDuration(target, tsMs);
|
||
if (target === model.openThinking) model.openThinking = null;
|
||
model.pendingThinking = null;
|
||
return;
|
||
}
|
||
const item: ThinkingItem = {
|
||
kind: "thinking",
|
||
id: nextId(model),
|
||
thinking: p.thinking,
|
||
streaming: false,
|
||
};
|
||
if (p.stop_reason !== undefined) item.stopReason = p.stop_reason;
|
||
// History rebuild (no fragment): approximate the thinking start with the previous message's time.
|
||
if (model.lastTsMs > 0) item.startedAtMs = model.lastTsMs;
|
||
settleThinkingDuration(item, tsMs);
|
||
model.items.push(item);
|
||
return;
|
||
}
|
||
case "tool_call": {
|
||
touchTask(model, timestamp);
|
||
const tsMs = tsOf(timestamp);
|
||
// A card that's already a complete call receives another complete tool_call with the same id:
|
||
// not a duplicate delivery (duplicates were already caught by dedup) but **another** call reusing
|
||
// the id — as seen in legacy Traces from a name-as-id provider (e.g. Gemini using the function
|
||
// name as tool_call_id). Take the create branch and start a new card (createToolCard repoints the
|
||
// Map to the newest card, so later output/approval attribute by id to the newest); never overwrite the old card.
|
||
const existing = model.toolCards.get(p.tool_call_id);
|
||
const card = existing?.callComplete ? undefined : existing;
|
||
if (card) {
|
||
card.name = p.name;
|
||
card.argumentsText = p.arguments;
|
||
card.callStreaming = false;
|
||
card.callComplete = true;
|
||
if (p.stop_reason !== undefined) card.callStopReason = p.stop_reason;
|
||
if (tsMs !== undefined) card.callStartedAtMs = tsMs;
|
||
noteApprovalWait(model, card); // Approval arrived first (mid-stream join): only here do both timestamps come together
|
||
settleUndispatchedCall(card);
|
||
return;
|
||
}
|
||
if (existing && !existing.outputComplete) {
|
||
// If the replaced old card is still "executing" (output not closed): the Map is about to
|
||
// repoint to the new card, so the old card will never get output — close it as aborted to stop
|
||
// the running timer (same behavior as closeExecutingToolCards).
|
||
existing.outputComplete = true;
|
||
existing.outputStreaming = false;
|
||
existing.outputStopReason ??= "aborted";
|
||
}
|
||
const created = createToolCard(model, {
|
||
toolCallId: p.tool_call_id,
|
||
name: p.name,
|
||
argumentsText: p.arguments,
|
||
callStreaming: false,
|
||
});
|
||
created.callComplete = true;
|
||
if (p.stop_reason !== undefined) created.callStopReason = p.stop_reason;
|
||
if (tsMs !== undefined) created.callStartedAtMs = tsMs;
|
||
noteApprovalWait(model, created); // createToolCard may have already backfilled a pending approval timestamp
|
||
// History rebuild (no partial_tool_call start): approximate "argument
|
||
// generation started" with the previous message's time, same
|
||
// convention as thinking. Otherwise the tool duration would lose its
|
||
// argument-generation segment (often the bulk of it) after a refresh, for no reason.
|
||
if (model.lastTsMs > 0) created.argStartedAtMs = model.lastTsMs;
|
||
settleUndispatchedCall(created);
|
||
return;
|
||
}
|
||
case "tool_call_output": {
|
||
touchTask(model, timestamp);
|
||
let card = model.toolCards.get(p.tool_call_id);
|
||
if (!card) {
|
||
// Mid-stream join: create a card if the call card is missing (name unknown, UI falls back to showing tool_call_id).
|
||
card = createToolCard(model, {
|
||
toolCallId: p.tool_call_id,
|
||
name: "",
|
||
argumentsText: "",
|
||
callStreaming: false,
|
||
});
|
||
}
|
||
card.output = p.output;
|
||
// The complete message converges the images (the streamed delta already carried them once; overwrites with the same value; also serves as a fallback for a mid-stream join).
|
||
if (p.images && p.images.length > 0) card.images = p.images;
|
||
card.outputStreaming = false;
|
||
card.outputComplete = true;
|
||
if (p.stop_reason !== undefined) card.outputStopReason = p.stop_reason;
|
||
settleToolDuration(card, tsOf(timestamp));
|
||
return;
|
||
}
|
||
// inline_data / inline_thinking: same convention as the CLI's history rendering — not shown for now.
|
||
case "inline_data":
|
||
case "inline_thinking":
|
||
touchTask(model, timestamp);
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close tool cards still "executing" (call complete, output not yet
|
||
* arrived): after an interruption or Task finalization, these cards will
|
||
* never get a tool_call_output, so mark output complete to stop the view
|
||
* layer's rolling timer; the duration stays unset and isn't shown. The
|
||
* stop reason is recorded as aborted — these tools **never produced a
|
||
* result**, and leaving it unset would render as a "completed" checkmark,
|
||
* visually indistinguishable from "executed successfully but with empty output".
|
||
* A late-arriving complete tool_call_output (if any) still overrides unconditionally, unaffected by this.
|
||
*/
|
||
function closeExecutingToolCards(model: StreamModel): void {
|
||
for (const card of model.toolCards.values()) {
|
||
if (card.callComplete && !card.outputComplete) {
|
||
card.outputComplete = true;
|
||
card.outputStreaming = false;
|
||
card.outputStopReason ??= "aborted";
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A tool_call that closed with a non-completed status (produced by a
|
||
* timeout/malformed interrupt closure) was never dispatched for execution
|
||
* and will never get a tool_call_output: settle the card by its closing
|
||
* reason as soon as it arrives, so the execution timer doesn't keep spinning forever.
|
||
*/
|
||
function settleUndispatchedCall(card: ToolCallItem): void {
|
||
if (!card.callStopReason || card.callStopReason === "completed" || card.outputComplete) return;
|
||
card.outputComplete = true;
|
||
card.outputStreaming = false;
|
||
card.outputStopReason ??= card.callStopReason;
|
||
}
|
||
|
||
/** Settle the thinking duration: end time - start time (skipped if either is missing; negative values clamp to 0). */
|
||
function settleThinkingDuration(item: ThinkingItem, endMs: number | undefined): void {
|
||
if (endMs === undefined || item.startedAtMs === undefined) return;
|
||
item.durationMs = Math.max(0, endMs - item.startedAtMs);
|
||
}
|
||
|
||
/**
|
||
* Settle the tool duration = the argument-generation segment + the
|
||
* execution segment (excluding the human approval wait).
|
||
* - Argument-generation segment: callStartedAtMs − argStartedAtMs (tool_call
|
||
* from generation start to closing);
|
||
* - Execution segment: endMs − the execution start point (preferring the
|
||
* approval-granted timestamp approvalAtMs, deducting the approval wait;
|
||
* falling back to the call's closing timestamp callStartedAtMs when there's no approval event).
|
||
* Adding the two gives the tool call's total duration; a later-arriving
|
||
* tool_call_output only fills in the execution segment, never overwriting
|
||
* the already-settled generation segment.
|
||
* Degrades to a pure execution segment when the start point is missing, still never negative.
|
||
*/
|
||
function settleToolDuration(card: ToolCallItem, endMs: number | undefined): void {
|
||
if (endMs === undefined) return;
|
||
const execStart = card.approvalAtMs ?? card.callStartedAtMs;
|
||
if (execStart === undefined) return;
|
||
const genMs =
|
||
card.argStartedAtMs !== undefined && card.callStartedAtMs !== undefined
|
||
? Math.max(0, card.callStartedAtMs - card.argStartedAtMs)
|
||
: 0;
|
||
card.durationMs = genMs + Math.max(0, endMs - execStart);
|
||
}
|
||
|
||
/**
|
||
* Add this card's human approval wait (approval_decision timestamp − the
|
||
* tool_call's closing timestamp) into the currently unclosed Request, for
|
||
* request_end to deduct from the wall-clock duration (see StreamModel.openApprovalWaitMs).
|
||
*
|
||
* The normal order is tool_call arriving first, approval_decision later;
|
||
* joining the live stream mid-way can reverse this (the approval lands in
|
||
* pendingDecisions first, backfilled when the card is created). So whichever
|
||
* of the two timestamps arrives later triggers this, with
|
||
* approvalWaitCounted guarding against double-counting. An auto-approved
|
||
* interval is ≈0, so deducting it does no harm.
|
||
*/
|
||
function noteApprovalWait(model: StreamModel, card: ToolCallItem): void {
|
||
if (card.approvalWaitCounted) return;
|
||
const { callStartedAtMs: call, approvalAtMs: approval } = card;
|
||
if (call === undefined || approval === undefined) return;
|
||
card.approvalWaitCounted = true;
|
||
const wait = approval - call;
|
||
if (wait > 0) model.openApprovalWaitMs += wait;
|
||
}
|
||
|
||
function createToolCard(
|
||
model: StreamModel,
|
||
init: { toolCallId: string; name: string; argumentsText: string; callStreaming: boolean },
|
||
): ToolCallItem {
|
||
const item: ToolCallItem = {
|
||
kind: "tool_call",
|
||
id: nextId(model),
|
||
toolCallId: init.toolCallId,
|
||
name: init.name,
|
||
argumentsText: init.argumentsText,
|
||
callStreaming: init.callStreaming,
|
||
callComplete: false,
|
||
output: "",
|
||
outputStreaming: false,
|
||
outputComplete: false,
|
||
};
|
||
// An approval decision that arrived before the card: backfilled at creation time.
|
||
const pending = model.pendingDecisions.get(init.toolCallId);
|
||
if (pending !== undefined) {
|
||
item.decision = pending;
|
||
item.decisionSource = model.localDecisions.has(init.toolCallId) ? "manual" : "remote";
|
||
model.pendingDecisions.delete(init.toolCallId);
|
||
const pendingTs = model.pendingDecisionTs.get(init.toolCallId);
|
||
if (pendingTs !== undefined) {
|
||
item.approvalAtMs = pendingTs;
|
||
model.pendingDecisionTs.delete(init.toolCallId);
|
||
}
|
||
}
|
||
model.toolCards.set(init.toolCallId, item);
|
||
model.items.push(item);
|
||
return item;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number, nowMs?: number): void {
|
||
switch (p.type) {
|
||
case "approval_decision": {
|
||
const card = model.toolCards.get(p.tool_call_id);
|
||
if (card) {
|
||
card.decision = p.decision;
|
||
card.decisionSource = model.localDecisions.has(p.tool_call_id) ? "manual" : "remote";
|
||
// Approval-granted timestamp: execution timing starts from here (deducting the approval wait).
|
||
if (tsMs !== undefined && card.approvalAtMs === undefined) card.approvalAtMs = tsMs;
|
||
noteApprovalWait(model, card); // Normal order: approval arrives later, both timestamps are already available here
|
||
} else {
|
||
model.pendingDecisions.set(p.tool_call_id, p.decision);
|
||
if (tsMs !== undefined) model.pendingDecisionTs.set(p.tool_call_id, tsMs);
|
||
}
|
||
return;
|
||
}
|
||
case "abort": {
|
||
// In-flight tools won't get any more output after an interruption (a
|
||
// placeholder resend goes only to the model, never written to Trace): finalize the executing cards.
|
||
closeExecutingToolCards(model);
|
||
// A reconnect hint waiting to retry: an interruption means retries are
|
||
// exhausted/abandoned, so mark it gaveUp (this interruption marker item gives the reason);
|
||
// the run has ended, so reset the consecutive-failure count.
|
||
const waiting = findLastWaitingReconnect(model);
|
||
if (waiting) waiting.gaveUp = true;
|
||
model.reconnectRun = 0;
|
||
const item: AbortItem = { kind: "abort", id: nextId(model) };
|
||
if (p.reason != null) item.reason = p.reason;
|
||
model.items.push(item);
|
||
return;
|
||
}
|
||
case "token_usage":
|
||
trackMainUsage(model.stats, p);
|
||
return;
|
||
case "compaction_begin": {
|
||
beginCompaction(model.stats);
|
||
model.items.push({
|
||
kind: "compaction",
|
||
id: nextId(model),
|
||
reason: p.reason,
|
||
mode: p.mode,
|
||
running: true,
|
||
});
|
||
return;
|
||
}
|
||
case "compaction_end": {
|
||
// status decides whether context usage is cleared: when not completed, the original context is kept (see endCompaction).
|
||
endCompaction(model.stats, p.status);
|
||
const item = findLastRunningCompaction(model);
|
||
if (item) {
|
||
item.running = false;
|
||
item.status = p.status;
|
||
} else {
|
||
// Mid-stream join (missed the begin): append a completed banner directly.
|
||
const created: CompactionItem = {
|
||
kind: "compaction",
|
||
id: nextId(model),
|
||
reason: p.reason,
|
||
mode: p.mode,
|
||
running: false,
|
||
status: p.status,
|
||
};
|
||
model.items.push(created);
|
||
}
|
||
return;
|
||
}
|
||
case "request_begin": {
|
||
// A retry request was sent: mark the waiting reconnect hint as resent (a no-op when there's no such item before a normal first request).
|
||
const waiting = findLastWaitingReconnect(model);
|
||
if (waiting) waiting.retrying = true;
|
||
// Record this Request's start (for output TPS timing); compaction requests aren't timed.
|
||
if (!model.stats.compactionActive) {
|
||
model.openRequestBeginMs = tsMs ?? null;
|
||
model.openApprovalWaitMs = 0;
|
||
}
|
||
return;
|
||
}
|
||
case "request_end": {
|
||
// timeout/malformed: the engine retries carrying the content already
|
||
// produced, rendering a retry hint (with the attempt number); other
|
||
// terminal statuses aren't rendered (Request duration is covered by Trace performance
|
||
// analysis) and reset the consecutive-failure count. request events
|
||
// within a compaction range (only visible during history rebuild)
|
||
// are neither rendered nor counted — the compaction process only exposes the compaction event pair to the Human.
|
||
if (model.stats.compactionActive) return;
|
||
// Credentials lifecycle rides on the request's own terminal status:
|
||
// - "auth" records WHEN the failure happened (the event's envelope time), so the
|
||
// composer gate can compare it against the Project's credentials-updated time
|
||
// (see lastAuthFailureMs / isModelAuthDead). Origin routing already sends a
|
||
// subagent's request events to the nested model, so reaching here with "auth"
|
||
// always means the failure belongs to THIS session.
|
||
// - a completed request proves the credentials work (again): the auth-dead state
|
||
// must not outlive a success. Live and history replay share this path, so a Trace
|
||
// with an auth failure followed by a completed request does not resurrect the dead
|
||
// composer on reload.
|
||
if (p.status === "auth") model.lastAuthFailureMs = tsMs ?? Date.now();
|
||
if (p.status === "completed") model.lastAuthFailureMs = null;
|
||
// Pairs with request_begin to compute this Request's wall-clock
|
||
// duration, deducts the human approval wait, and adds the result to
|
||
// this Task's LLM time (for output TPS) — this duration includes tool
|
||
// argument generation but excludes tool execution (which happens
|
||
// between two Requests) and excludes the human approval wait (see openApprovalWaitMs).
|
||
if (model.openRequestBeginMs !== null && tsMs !== undefined) {
|
||
addLlmDuration(model.stats, tsMs - model.openRequestBeginMs - model.openApprovalWaitMs);
|
||
}
|
||
model.openRequestBeginMs = null;
|
||
model.openApprovalWaitMs = 0;
|
||
// This is now the round's end (so far): update taskLastReqEndMs, with
|
||
// the duration taken as "it − the first message". This also settles
|
||
// compaction's Token attribution — reaching this point means a
|
||
// pending compaction is followed by this round's normal Request (a
|
||
// compaction triggered **mid-round**, which keeps running with a
|
||
// carry-over after compacting), so its usage belongs to this round
|
||
// and is settled into this round's cost. After a finalization
|
||
// compaction / manual /compact, there's no more Request in this
|
||
// round, so the pending compaction usage never reaches this step and is discarded at finalization (not counted into this round).
|
||
if (tsMs !== undefined) model.taskLastReqEndMs = tsMs;
|
||
commitPendingCompaction(model.stats);
|
||
if (p.status === "timeout" || p.status === "malformed") {
|
||
model.reconnectRun += 1;
|
||
const item: ReconnectItem = {
|
||
kind: "reconnect",
|
||
id: nextId(model),
|
||
status: p.status,
|
||
attempt: model.reconnectRun,
|
||
retrying: false,
|
||
};
|
||
// The engine announced its planned backoff: keep it with the CLIENT arrival time
|
||
// as the countdown anchor (skew-free — see ReconnectItem.arrivedAtMs).
|
||
if (typeof p.retry_in_ms === "number" && p.retry_in_ms > 0) {
|
||
item.plannedDelayMs = p.retry_in_ms;
|
||
item.arrivedAtMs = nowMs ?? Date.now();
|
||
}
|
||
model.items.push(item);
|
||
} else {
|
||
model.reconnectRun = 0;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function findLastRunningCompaction(model: StreamModel): CompactionItem | null {
|
||
for (let i = model.items.length - 1; i >= 0; i--) {
|
||
const item = model.items[i]!;
|
||
if (item.kind === "compaction" && item.running) return item;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findLastWaitingReconnect(model: StreamModel): ReconnectItem | null {
|
||
for (let i = model.items.length - 1; i >= 0; i--) {
|
||
const item = model.items[i]!;
|
||
if (item.kind === "reconnect") {
|
||
return !item.retrying && !item.gaveUp ? item : null; // earlier items each already have a resolution, don't look further back
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// origin nested routing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function routeNested(model: StreamModel, msg: OmniMessage, nowMs: number): void {
|
||
const head = msg.origin![0]!;
|
||
// Sub-session token_usage: the request delta counts toward this level's stats (at any depth, same convention as the CLI).
|
||
if (isEventMessage(msg) && msg.payload.type === "token_usage") {
|
||
trackSubagentUsage(model.stats, msg.payload as TokenUsagePayload);
|
||
}
|
||
touchTask(model, msg.timestamp);
|
||
|
||
let sub = model.subagents.get(head);
|
||
if (!sub) {
|
||
sub = newModel(true, model.localDecisions);
|
||
sub.firstSeenLocalMs = nowMs;
|
||
model.subagents.set(head, sub);
|
||
bindSubagent(model, head, sub);
|
||
}
|
||
// Elapsed-time stamps (see the StreamModel field docs): every message routed into this child's
|
||
// subtree — its own or a deeper descendant's — counts as its activity; the recursive pushMessage
|
||
// below stamps the deeper hops the same way.
|
||
sub.lastActivityLocalMs = nowMs;
|
||
const activityTs = tsOf(msg.timestamp);
|
||
if (activityTs !== undefined) {
|
||
if (sub.firstTsMs === undefined) sub.firstTsMs = activityTs;
|
||
if (sub.lastActivityTsMs === undefined || activityTs > sub.lastActivityTsMs) {
|
||
sub.lastActivityTsMs = activityTs;
|
||
}
|
||
}
|
||
// Strip the first origin hop and recursively feed into the nested model.
|
||
const rest = msg.origin!.slice(1);
|
||
const forwarded: OmniMessage = { ...msg };
|
||
if (rest.length > 0) forwarded.origin = rest;
|
||
else delete forwarded.origin;
|
||
pushMessage(sub, forwarded, nowMs);
|
||
}
|
||
|
||
/**
|
||
* Binding rule: bind to the most recent allowed
|
||
* (decision=allow) and not-yet-complete (output not yet complete)
|
||
* run_subagent tool card that hasn't been bound to an origin yet; append a standalone SubagentItem if none is found.
|
||
*/
|
||
function bindSubagent(model: StreamModel, sessionId: string, sub: StreamModel): void {
|
||
for (let i = model.items.length - 1; i >= 0; i--) {
|
||
const item = model.items[i]!;
|
||
if (
|
||
item.kind === "tool_call" &&
|
||
item.name === "run_subagent" &&
|
||
!item.subagent &&
|
||
!item.outputComplete &&
|
||
item.decision === "allow"
|
||
) {
|
||
item.subagent = sub;
|
||
item.subagentSessionId = sessionId;
|
||
return;
|
||
}
|
||
}
|
||
model.items.push({ kind: "subagent", id: nextId(model), sessionId, model: sub });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Overlap dedup (connect-first + dedup)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Build a dedup index from the envelope JSON of history's **last `limit` messages**. */
|
||
export function buildDedupIndex(messages: OmniMessage[], limit = 100): Set<string> {
|
||
const index = new Set<string>();
|
||
for (let i = Math.max(0, messages.length - limit); i < messages.length; i++) {
|
||
index.add(JSON.stringify(messages[i]));
|
||
}
|
||
return index;
|
||
}
|
||
|
||
/** Determine whether a complete message/event is exactly identical to history's envelope JSON (overlap dedup). */
|
||
export function isDuplicate(index: Set<string>, msg: OmniMessage): boolean {
|
||
return index.has(JSON.stringify(msg));
|
||
}
|
||
|
||
/**
|
||
* When a complete message hits the dedup check, discard the corresponding
|
||
* in-flight streamed fragment: if a streamed copy was fed
|
||
* into the reducer before this complete message, its content duplicates
|
||
* history and must be entirely removed/cleared. Routed recursively to nested models by origin.
|
||
*/
|
||
export function discardFragmentFor(model: StreamModel, msg: OmniMessage): void {
|
||
if (msg.origin && msg.origin.length > 0) {
|
||
const sub = model.subagents.get(msg.origin[0]!);
|
||
if (!sub) return;
|
||
const rest = msg.origin.slice(1);
|
||
const forwarded: OmniMessage = { ...msg };
|
||
if (rest.length > 0) forwarded.origin = rest;
|
||
else delete forwarded.origin;
|
||
discardFragmentFor(sub, forwarded);
|
||
return;
|
||
}
|
||
if (msg.type !== "model_msg" || isPartialPayload(msg.payload)) return;
|
||
const p = msg.payload as CompleteModelPayload;
|
||
switch (p.type) {
|
||
case "text": {
|
||
if (p.role !== "assistant") return;
|
||
const target = model.pendingText ?? model.openText;
|
||
if (target) {
|
||
removeItem(model, target);
|
||
if (target === model.openText) model.openText = null;
|
||
model.pendingText = null;
|
||
}
|
||
return;
|
||
}
|
||
case "thinking": {
|
||
const target = model.pendingThinking ?? model.openThinking;
|
||
if (target) {
|
||
removeItem(model, target);
|
||
if (target === model.openThinking) model.openThinking = null;
|
||
model.pendingThinking = null;
|
||
}
|
||
return;
|
||
}
|
||
case "tool_call": {
|
||
const card = model.toolCards.get(p.tool_call_id);
|
||
if (card && !card.callComplete) {
|
||
removeItem(model, card);
|
||
model.toolCards.delete(p.tool_call_id);
|
||
}
|
||
return;
|
||
}
|
||
case "tool_call_output": {
|
||
const card = model.toolCards.get(p.tool_call_id);
|
||
if (card && !card.outputComplete) {
|
||
card.output = "";
|
||
card.outputStreaming = false;
|
||
}
|
||
return;
|
||
}
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
function removeItem(model: StreamModel, item: ChatItem): void {
|
||
const idx = model.items.indexOf(item);
|
||
if (idx >= 0) model.items.splice(idx, 1);
|
||
}
|