@saadnvd1/agent-os 0.3.738 → 0.3.742

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ // Retrying a turn the model's server dropped. OpenCode and Pi retry some
2
+ // failures themselves (and say so); this covers the rest, with a hard limit
3
+ // so nothing loops: a few tries with growing waits, the last one on a
4
+ // smaller context, then the turn ends with a report.
5
+
6
+ export type ErrorKind = "timeout" | "transient";
7
+
8
+ export const MAX_RETRIES = 3;
9
+ const BASE_MS = 2_000;
10
+ // A context this full (percent of the window) is compacted before the last
11
+ // try: a request that took too long to start is the one that shrinks.
12
+ export const COMPACT_AT_PERCENT = 50;
13
+
14
+ const NEVER =
15
+ /context (length|window)|maximum context|too many tokens|prompt is too long|\b40[0-4]\b|unauthori[sz]ed|invalid api key|authentication|forbidden|abort/i;
16
+ const TIMEOUT =
17
+ /headers timed out|timed out|timeout|UND_ERR_(HEADERS|BODY|CONNECT)_TIMEOUT|ETIMEDOUT|ResponseStreamError/i;
18
+ const TRANSIENT =
19
+ /fetch failed|socket hang up|ECONNRESET|ECONNREFUSED|EPIPE|terminated|connection (lost|closed|reset)|ended without|overloaded|\b(429|500|502|503|504|524)\b|unavailable|rate limit/i;
20
+
21
+ // Whether a provider error is worth another try, and which kind it is.
22
+ export function classifyProviderError(message: string): ErrorKind | undefined {
23
+ if (!message || NEVER.test(message)) return undefined;
24
+ if (TIMEOUT.test(message)) return "timeout";
25
+ if (TRANSIENT.test(message)) return "transient";
26
+ return undefined;
27
+ }
28
+
29
+ export function backoffMs(attempt: number, jitter = true): number {
30
+ const base = BASE_MS * 2 ** (attempt - 1);
31
+ return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base;
32
+ }
33
+
34
+ export type RetryDecision =
35
+ | { retry: true; attempt: number; delayMs: number; compactFirst: boolean }
36
+ | { retry: false; attempts: number; kind?: ErrorKind };
37
+
38
+ // One per conversation. A turn that sends the user's message starts it over;
39
+ // a retry never does.
40
+ export class TurnRetry {
41
+ private attempts = 0;
42
+
43
+ constructor(private jitter = true) {}
44
+
45
+ reset(): void {
46
+ this.attempts = 0;
47
+ }
48
+
49
+ // Retries made in this turn.
50
+ get tried(): number {
51
+ return this.attempts;
52
+ }
53
+
54
+ next(o: {
55
+ error: string;
56
+ // The agent already retried this failure itself.
57
+ harnessRetried?: boolean;
58
+ // The context is big enough that compacting it is worth a try.
59
+ contextHeavy?: boolean;
60
+ }): RetryDecision {
61
+ const kind = classifyProviderError(o.error);
62
+ if (!kind || o.harnessRetried || this.attempts >= MAX_RETRIES)
63
+ return { retry: false, attempts: this.attempts, kind };
64
+ this.attempts++;
65
+ return {
66
+ retry: true,
67
+ attempt: this.attempts,
68
+ delayMs: backoffMs(this.attempts, this.jitter),
69
+ compactFirst: this.attempts === MAX_RETRIES && !!o.contextHeavy,
70
+ };
71
+ }
72
+ }
73
+
74
+ // The message that resumes a turn after a failure: the steps that ran are
75
+ // already in the conversation.
76
+ export function continuePrompt(error: string): string {
77
+ const why = error.replace(/\s+/g, " ").trim().slice(0, 200);
78
+ return `The previous request to the model failed (${why}). Continue the task from where it stopped. The tool steps above already ran: don't redo them.`;
79
+ }
80
+
81
+ export function retryNotice(
82
+ d: Extract<RetryDecision, { retry: true }>,
83
+ error: string
84
+ ): string {
85
+ const why = error.replace(/\s+/g, " ").trim().slice(0, 160);
86
+ return `The model's server didn't answer (${why}). Trying again in ${Math.round(d.delayMs / 1000)}s (${d.attempt} of ${MAX_RETRIES})${d.compactFirst ? ", after compacting the conversation" : ""}.`;
87
+ }
@@ -6,12 +6,14 @@ import type {
6
6
  ToolStatus,
7
7
  TurnEndReason,
8
8
  } from "../events";
9
- import { clipOutput } from "../tools";
9
+ import { clipOutput, tailOutput } from "../tools";
10
10
 
11
11
  type Tool = Extract<ChatItem, { kind: "tool" }>;
12
12
  type Text = Extract<ChatItem, { kind: "assistant" | "reasoning" }>;
13
13
  type Todo = Extract<ChatItem, { kind: "todos" }>["todos"][number];
14
14
 
15
+ const PROGRESS_MS = 250;
16
+
15
17
  export interface ToolStart {
16
18
  name: string;
17
19
  title: string;
@@ -19,6 +21,18 @@ export interface ToolStart {
19
21
  diff?: FileDiff;
20
22
  }
21
23
 
24
+ export interface TurnSummary {
25
+ steps: number;
26
+ failed: number;
27
+ files: { path: string; ok: number; failed: number }[];
28
+ }
29
+
30
+ const newStats = () => ({
31
+ steps: 0,
32
+ failed: 0,
33
+ files: new Map<string, { ok: number; failed: number }>(),
34
+ });
35
+
22
36
  // An API's error as its message: providers wrap JSON in JSON.
23
37
  export function readableError(text: string): string {
24
38
  let message = text.trim();
@@ -50,6 +64,9 @@ export class TurnItems {
50
64
  // Texts that finished in this turn, so one can be corrected afterwards.
51
65
  private done = new Map<string, Text>();
52
66
  private startedAt = 0;
67
+ // What the turn has done so far, for the report when it stops short.
68
+ private stats = newStats();
69
+ private progressAt = new Map<string, number>();
53
70
  inTurn = false;
54
71
 
55
72
  constructor(private emit: (e: DriverEvent) => void) {}
@@ -58,6 +75,7 @@ export class TurnItems {
58
75
  if (this.inTurn) return;
59
76
  this.inTurn = true;
60
77
  this.startedAt = Date.now();
78
+ this.stats = newStats();
61
79
  this.emit({ type: "turn_start" });
62
80
  }
63
81
 
@@ -121,6 +139,20 @@ export class TurnItems {
121
139
  this.emit({ type: "item", item });
122
140
  }
123
141
 
142
+ // A running step's output so far (the whole of it each time, not a
143
+ // delta). Sent at most every PROGRESS_MS; the end replaces it.
144
+ progress(id: string, output: string): void {
145
+ const tool = this.tools.get(id);
146
+ if (!tool || !output) return;
147
+ const tail = tailOutput(output);
148
+ if (tail === tool.output) return;
149
+ const now = Date.now();
150
+ if (now - (this.progressAt.get(id) ?? 0) < PROGRESS_MS) return;
151
+ this.progressAt.set(id, now);
152
+ tool.output = tail;
153
+ this.emit({ type: "item", item: { ...tool } });
154
+ }
155
+
124
156
  hasText(key: string): boolean {
125
157
  return this.texts.has(key);
126
158
  }
@@ -138,6 +170,8 @@ export class TurnItems {
138
170
  const tool = this.tools.get(id);
139
171
  if (!tool) return;
140
172
  this.tools.delete(id);
173
+ this.count(tool, update, status);
174
+ this.progressAt.delete(id);
141
175
  this.emit({
142
176
  type: "item",
143
177
  item: {
@@ -153,6 +187,31 @@ export class TurnItems {
153
187
  });
154
188
  }
155
189
 
190
+ private count(
191
+ tool: Tool,
192
+ update: Partial<ToolStart> | undefined,
193
+ status: ToolStatus
194
+ ): void {
195
+ if (status === "stopped") return;
196
+ this.stats.steps++;
197
+ if (status === "error") this.stats.failed++;
198
+ const input = (update?.input ?? tool.input) as { file_path?: unknown };
199
+ if (!/^(Edit|Write|MultiEdit)$/.test(tool.name)) return;
200
+ if (typeof input?.file_path !== "string") return;
201
+ const file = this.stats.files.get(input.file_path) ?? { ok: 0, failed: 0 };
202
+ file[status === "error" ? "failed" : "ok"]++;
203
+ this.stats.files.set(input.file_path, file);
204
+ }
205
+
206
+ // The turn so far: tool steps run, how many failed, files edited.
207
+ summary(): TurnSummary {
208
+ return {
209
+ steps: this.stats.steps,
210
+ failed: this.stats.failed,
211
+ files: [...this.stats.files].map(([path, n]) => ({ path, ...n })),
212
+ };
213
+ }
214
+
156
215
  todos(todos: Todo[]): void {
157
216
  this.emit({
158
217
  type: "item",
@@ -160,6 +219,20 @@ export class TurnItems {
160
219
  });
161
220
  }
162
221
 
222
+ // A line in the conversation that isn't the agent's: a retry under way.
223
+ note(text: string): void {
224
+ this.emit({
225
+ type: "item",
226
+ item: {
227
+ id: randomUUID(),
228
+ kind: "note",
229
+ text,
230
+ tone: "note",
231
+ createdAt: Date.now(),
232
+ },
233
+ });
234
+ }
235
+
163
236
  error(message: string): void {
164
237
  this.emit({
165
238
  type: "item",
@@ -1,5 +1,7 @@
1
1
  import { redact } from "../../orchestrator/untrusted";
2
2
  import type { TurnEndReason } from "../events";
3
+ import type { TurnSummary } from "./turn-items";
4
+ import { classifyProviderError } from "./transient-retry";
3
5
 
4
6
  export interface TurnEnded {
5
7
  reason: TurnEndReason;
@@ -43,11 +45,49 @@ export function hasToolMarkup(text: string): boolean {
43
45
  );
44
46
  }
45
47
 
48
+ const plural = (n: number, one: string, many = `${one}s`) =>
49
+ `${n} ${n === 1 ? one : many}`;
50
+
51
+ // Where a turn stopped when the model's server kept failing: what the model
52
+ // got done, what it didn't, and what to do next. The error text is scrubbed.
53
+ export function failureReport(o: {
54
+ error: string;
55
+ retries: number;
56
+ // The agent retried it before AgentOS saw the error.
57
+ agentRetried?: boolean;
58
+ summary?: TurnSummary;
59
+ }): string {
60
+ const kind = classifyProviderError(o.error);
61
+ const why = redact(o.error.replace(/\s+/g, " ").trim()).slice(0, 240);
62
+ const tries = o.retries
63
+ ? ` and ${plural(o.retries, "retry", "retries")} didn't get through`
64
+ : o.agentRetried
65
+ ? " even after the agent retried it"
66
+ : "";
67
+ const s = o.summary;
68
+ const done = s?.steps
69
+ ? `Finished before it stopped: ${plural(s.steps, "tool step")}${s.failed ? ` (${s.failed} failed)` : ""}${
70
+ s.files.length
71
+ ? `; edits to ${s.files
72
+ .map(
73
+ (f) =>
74
+ `${f.path}${f.failed ? ` (${f.failed} failed${f.ok ? `, ${f.ok} applied` : ""})` : ""}`
75
+ )
76
+ .join(", ")}`
77
+ : ""
78
+ }.`
79
+ : "Nothing had finished yet.";
80
+ return `The model's server ${kind === "timeout" ? "timed out" : "kept failing"} (${why})${tries}. ${done} Not finished: the model's next reply, so the task is incomplete. Send "continue" to resume; the finished steps are kept.`;
81
+ }
82
+
46
83
  export function turnOutcome(r: {
47
84
  finishReason?: string;
48
85
  text: string;
49
86
  toolCalls: number;
50
87
  error?: string;
88
+ retries?: number;
89
+ agentRetried?: boolean;
90
+ summary?: TurnSummary;
51
91
  }): TurnEnded | undefined {
52
92
  if (r.finishReason === "length")
53
93
  return {
@@ -58,7 +98,14 @@ export function turnOutcome(r: {
58
98
  if (r.error)
59
99
  return {
60
100
  reason: "error",
61
- notice: "The model's server returned an error, shown above.",
101
+ notice: classifyProviderError(r.error)
102
+ ? failureReport({
103
+ error: r.error,
104
+ retries: r.retries ?? 0,
105
+ agentRetried: r.agentRetried,
106
+ summary: r.summary,
107
+ })
108
+ : "The model's server returned an error, shown above.",
62
109
  };
63
110
  if (r.toolCalls > 0) return undefined;
64
111
  if (hasToolMarkup(r.text))
@@ -322,6 +322,14 @@ export interface Compacting {
322
322
  before?: number;
323
323
  }
324
324
 
325
+ // A model call the turn is waiting on: since when, the last measured prompt
326
+ // size (a floor: the next prompt is at least that big), and a retry under way.
327
+ export interface ModelWait {
328
+ since: number;
329
+ promptTokens?: number;
330
+ retry?: { attempt: number; message?: string };
331
+ }
332
+
325
333
  export interface UndoPreview {
326
334
  canUndo: boolean;
327
335
  error?: string;
@@ -347,7 +355,11 @@ export type DriverEvent =
347
355
  | { type: "at_rest" } // it has run every message it was given
348
356
  | { type: "state"; state: ChatState }
349
357
  // The agent started (true) or finished (false) compacting its context.
350
- | { type: "compacting"; active: boolean };
358
+ | { type: "compacting"; active: boolean }
359
+ // One model response's measured prompt (input plus cache), in tokens.
360
+ | { type: "prompt_tokens"; tokens: number }
361
+ // The agent is retrying a failed model request.
362
+ | { type: "model_retry"; attempt: number; message?: string };
351
363
 
352
364
  // A tool call's output and diff, loaded when it's opened.
353
365
  export interface ToolBody {
@@ -368,6 +380,7 @@ export type ChatServerMessage =
368
380
  tasks: ChatItem[];
369
381
  state: ChatState;
370
382
  compacting?: Compacting | null;
383
+ modelWait?: ModelWait | null;
371
384
  queue: QueuedMessage[];
372
385
  suggestion: string | null;
373
386
  }
@@ -393,6 +406,7 @@ export type ChatServerMessage =
393
406
  | { type: "delta"; id: string; text: string }
394
407
  | { type: "state"; state: ChatState }
395
408
  | { type: "compacting"; compacting: Compacting | null }
409
+ | { type: "model_wait"; wait: ModelWait | null }
396
410
  | {
397
411
  type: "capabilities";
398
412
  commands: ChatCommand[];
@@ -0,0 +1,19 @@
1
+ import { emit, type Live } from "./registry";
2
+ import { ModelWaitTracker } from "./model-wait";
3
+ import type { WorkerEvent } from "./worker/protocol";
4
+
5
+ // Keeps the model call the turn waits on current; the events that only feed
6
+ // it (prompt size, a retry) go no further.
7
+ export function trackModelWait(
8
+ sessionId: string,
9
+ live: Live,
10
+ e: WorkerEvent
11
+ ): boolean {
12
+ const tracker = (live.waitTracker ??= new ModelWaitTracker());
13
+ const changed = tracker.on(e);
14
+ if (changed) {
15
+ live.modelWait = tracker.wait;
16
+ emit(sessionId, { type: "model_wait", wait: tracker.wait });
17
+ }
18
+ return e.type === "prompt_tokens" || e.type === "model_retry";
19
+ }
@@ -0,0 +1,88 @@
1
+ import type { ModelWait } from "./events";
2
+ import type { WorkerEvent } from "./worker/protocol";
3
+
4
+ // Follows a running turn's events to know when it is waiting on a model
5
+ // call: no tool running, nothing streaming, not compacting. Only what a
6
+ // driver measured is kept as the prompt size; nothing is guessed.
7
+ export class ModelWaitTracker {
8
+ wait: ModelWait | null = null;
9
+ private lastPrompt: number | undefined;
10
+ private running = new Set<string>();
11
+ private active = false;
12
+
13
+ // Returns whether `wait` changed.
14
+ on(e: WorkerEvent, now = Date.now()): boolean {
15
+ const before = this.wait;
16
+ switch (e.type) {
17
+ case "state":
18
+ if (e.state === "running") {
19
+ if (!this.active) {
20
+ this.active = true;
21
+ this.begin(now);
22
+ }
23
+ } else this.end();
24
+ break;
25
+ case "item": {
26
+ const i = e.item;
27
+ if (i.kind === "turn_end") this.end();
28
+ else if (i.kind === "compacted") this.lastPrompt = undefined;
29
+ else if (i.kind === "tool") {
30
+ if (i.status === "running") {
31
+ this.running.add(i.id);
32
+ this.wait = null;
33
+ } else {
34
+ this.running.delete(i.id);
35
+ if (this.active && !this.running.size) this.begin(now);
36
+ }
37
+ } else if (i.kind === "assistant" || i.kind === "reasoning") {
38
+ if (i.streaming) this.wait = null;
39
+ else if (this.active && !this.running.size && !this.wait)
40
+ this.begin(now);
41
+ }
42
+ break;
43
+ }
44
+ case "delta":
45
+ this.wait = null;
46
+ break;
47
+ case "compacting":
48
+ if (e.compacting) {
49
+ this.wait = null;
50
+ this.lastPrompt = undefined;
51
+ } else if (this.active && !this.running.size) this.begin(now);
52
+ break;
53
+ case "prompt_tokens":
54
+ this.lastPrompt = e.tokens;
55
+ break;
56
+ case "context":
57
+ this.lastPrompt = e.context.usedTokens || this.lastPrompt;
58
+ break;
59
+ case "model_retry":
60
+ if (this.active)
61
+ this.wait = {
62
+ ...(this.wait ?? this.fresh(now)),
63
+ retry: { attempt: e.attempt, message: e.message },
64
+ };
65
+ break;
66
+ }
67
+ return (
68
+ before !== this.wait &&
69
+ JSON.stringify(before) !== JSON.stringify(this.wait)
70
+ );
71
+ }
72
+
73
+ private fresh(now: number): ModelWait {
74
+ return this.lastPrompt
75
+ ? { since: now, promptTokens: this.lastPrompt }
76
+ : { since: now };
77
+ }
78
+
79
+ private begin(now: number) {
80
+ this.wait = this.fresh(now);
81
+ }
82
+
83
+ private end() {
84
+ this.active = false;
85
+ this.wait = null;
86
+ this.running.clear();
87
+ }
88
+ }
@@ -0,0 +1,169 @@
1
+ import type { ChatItem, ChatState, Compacting, ModelWait } from "./events";
2
+ import { formatTokens } from "./context";
3
+
4
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
5
+
6
+ export type Phase = "planning" | "coding" | "testing" | "error";
7
+
8
+ // A wait on one model call this long gets a hint that it may be normal.
9
+ export const LONG_WAIT_MS = 2 * 60_000;
10
+ export const LONG_WAIT_HINT =
11
+ "Still waiting on the model; large prompts on local models can take minutes";
12
+
13
+ const TEST_RE =
14
+ /\b(test|tests|vitest|jest|pytest|mocha|playwright|tsc|typecheck|lint|eslint|build|check)\b|cargo (test|build|check)|go (test|build|vet)/;
15
+ const EDIT_TOOLS = new Set([
16
+ "Edit",
17
+ "Write",
18
+ "MultiEdit",
19
+ "NotebookEdit",
20
+ "apply_patch",
21
+ ]);
22
+ const VERBS: Record<string, string> = {
23
+ Read: "Reading",
24
+ Edit: "Editing",
25
+ Write: "Writing",
26
+ MultiEdit: "Editing",
27
+ NotebookEdit: "Editing",
28
+ Search: "Searching",
29
+ Find: "Finding",
30
+ Fetch: "Fetching",
31
+ List: "Listing",
32
+ Run: "Running",
33
+ };
34
+
35
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
36
+ const input = (t: Tool) => (t.input ?? {}) as Record<string, unknown>;
37
+ const command = (t: Tool) => str(input(t).command);
38
+
39
+ // "ollama/qwen3-coder:30b" -> "qwen3-coder:30b"; nothing known -> "the model".
40
+ export function shortModel(label: string | undefined): string {
41
+ let m = (label ?? "").trim().split("/").at(-1) ?? "";
42
+ m = m.replace(/:latest$/, "").replace(/-\d{8}$/, "");
43
+ if (!m || m.toLowerCase() === "default") return "the model";
44
+ return m.length > 24 ? `${m.slice(0, 23)}…` : m;
45
+ }
46
+
47
+ const tail = (path: string) => path.split("/").slice(-3).join("/");
48
+ const clip = (s: string, n = 60) =>
49
+ s.length > n ? `${s.slice(0, n - 1)}…` : s;
50
+
51
+ // A tool call in plain words: "Running npm test", "Editing src/a/b.ts".
52
+ export function stepLabel(t: Tool): string {
53
+ const cmd = command(t);
54
+ if (cmd) return `Running ${clip(cmd.replace(/\s+/g, " ").trim())}`;
55
+ const path = str(input(t).file_path);
56
+ if (path && EDIT_TOOLS.has(t.name)) {
57
+ const verb = VERBS[t.name] ?? "Editing";
58
+ return `${verb} ${tail(path)}`;
59
+ }
60
+ const [first, ...rest] = t.title.split(" ");
61
+ return VERBS[first] && rest.length
62
+ ? `${VERBS[first]} ${rest.join(" ")}`
63
+ : t.title;
64
+ }
65
+
66
+ export function turnTools(items: ChatItem[]): Tool[] {
67
+ let start = 0;
68
+ items.forEach((i, n) => {
69
+ if (i.kind === "user" && !i.midTurn) start = n + 1;
70
+ });
71
+ return items.slice(start).filter((i): i is Tool => i.kind === "tool");
72
+ }
73
+
74
+ const edited = (t: Tool) => EDIT_TOOLS.has(t.name) || !!t.diff;
75
+
76
+ // Planning, coding, testing or error, from the turn's tools: the current
77
+ // step (running, else the last one) decides error and testing; having
78
+ // edited anything in the turn makes the rest coding.
79
+ export function phaseOf(tools: Tool[]): Phase {
80
+ const step = tools.findLast((t) => t.status === "running") ?? tools.at(-1);
81
+ if (!step) return "planning";
82
+ if (step.status === "error") return "error";
83
+ if (TEST_RE.test(command(step))) return "testing";
84
+ return tools.some(edited) ? "coding" : "planning";
85
+ }
86
+
87
+ export interface Activity {
88
+ label: string;
89
+ since?: number;
90
+ compacting?: boolean;
91
+ kind: "answer" | "compacting" | "tool" | "writing" | "waiting" | "retry";
92
+ // The last measured prompt, when a driver reported one.
93
+ tokens?: number;
94
+ phase?: Phase;
95
+ steps: number;
96
+ // The last finished step, while no tool runs.
97
+ lastStep?: string;
98
+ turnSince?: number;
99
+ hint?: string;
100
+ }
101
+
102
+ // What the turn is doing now and for how long, from its items.
103
+ export function currentActivity(
104
+ items: ChatItem[],
105
+ state: ChatState,
106
+ compacting: Compacting | null = null,
107
+ modelWait: ModelWait | null = null,
108
+ model?: string,
109
+ now: number = Date.now()
110
+ ): Activity | null {
111
+ if (state === "waiting")
112
+ return { label: "Waiting for your answer", kind: "answer", steps: 0 };
113
+ if (state !== "running") return null;
114
+ if (compacting)
115
+ return {
116
+ label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
117
+ since: compacting.since,
118
+ compacting: true,
119
+ kind: "compacting",
120
+ steps: 0,
121
+ };
122
+ const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
123
+ const tools = turnTools(items);
124
+ const base = {
125
+ steps: tools.length,
126
+ turnSince: lastUser?.createdAt,
127
+ phase: tools.length ? phaseOf(tools) : undefined,
128
+ };
129
+ const tool = tools.findLast((t) => t.status === "running");
130
+ if (tool)
131
+ return {
132
+ ...base,
133
+ label: stepLabel(tool),
134
+ since: tool.createdAt,
135
+ kind: "tool",
136
+ };
137
+ const last = items.at(-1);
138
+ if (
139
+ last &&
140
+ (last.kind === "assistant" || last.kind === "reasoning") &&
141
+ last.streaming
142
+ )
143
+ return {
144
+ ...base,
145
+ label: last.kind === "assistant" ? "Writing" : "Thinking",
146
+ since: lastUser?.createdAt,
147
+ kind: "writing",
148
+ };
149
+ const done = tools.findLast((t) => t.status !== "running");
150
+ const since =
151
+ modelWait?.since ?? done?.endedAt ?? done?.createdAt ?? lastUser?.createdAt;
152
+ const retry = modelWait?.retry;
153
+ return {
154
+ ...base,
155
+ label: retry
156
+ ? `Retrying model request (attempt ${retry.attempt})`
157
+ : `Waiting for ${shortModel(model)}`,
158
+ since,
159
+ kind: retry ? "retry" : "waiting",
160
+ tokens: modelWait?.promptTokens,
161
+ lastStep: done
162
+ ? `${stepLabel(done)}${done.status === "error" ? " failed" : ""}`
163
+ : undefined,
164
+ hint:
165
+ since !== undefined && now - since > LONG_WAIT_MS
166
+ ? LONG_WAIT_HINT
167
+ : undefined,
168
+ };
169
+ }
@@ -10,9 +10,11 @@ import type {
10
10
  ChatServerMessage,
11
11
  ChatState,
12
12
  Compacting,
13
+ ModelWait,
13
14
  } from "./events";
14
15
  import { markStatusDirty } from "../status/dirty";
15
16
  import { FLUSH_MS } from "./store";
17
+ import type { ModelWaitTracker } from "./model-wait";
16
18
 
17
19
  // Long enough for the worker's buffered write of an item to land.
18
20
  const EMIT_SETTLE_MS = 2 * FLUSH_MS;
@@ -23,6 +25,9 @@ export interface Live {
23
25
  state: ChatState;
24
26
  // Set while its agent compacts the context, over a "running" state.
25
27
  compacting?: Compacting | null;
28
+ // The model call the turn is waiting on, if any (lib/chat/model-wait).
29
+ modelWait?: ModelWait | null;
30
+ waitTracker?: ModelWaitTracker;
26
31
  // Streamed items carry their latest text here, ahead of SQLite.
27
32
  streaming: Map<string, ChatItem>;
28
33
  // What the turn is doing right now, for the session list.