@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.
@@ -5,7 +5,7 @@ describe("currentActivity while compacting", () => {
5
5
  it("says so, with the size going in and when it began", () => {
6
6
  expect(
7
7
  currentActivity([], "running", { since: 5, before: 182_000 })
8
- ).toEqual({
8
+ ).toMatchObject({
9
9
  label: "Compacting context… 182k tokens",
10
10
  since: 5,
11
11
  compacting: true,
@@ -18,5 +18,6 @@ describe("currentActivity while compacting", () => {
18
18
  it("is plain work without it, and nothing once the turn is over", () => {
19
19
  expect(currentActivity([], "running")?.compacting).toBeUndefined();
20
20
  expect(currentActivity([], "idle", { since: 5 })).toBeNull();
21
+ expect(currentActivity([], "running")?.label).toBe("Waiting for the model");
21
22
  });
22
23
  });
@@ -1,91 +1,129 @@
1
1
  "use client";
2
2
 
3
- import { Loader2, MessageCircleQuestion, Shrink, Square } from "lucide-react";
4
- import type { ChatItem, ChatState, Compacting } from "@/lib/chat/events";
3
+ import {
4
+ AlertTriangle,
5
+ Loader2,
6
+ MessageCircleQuestion,
7
+ Shrink,
8
+ Square,
9
+ } from "lucide-react";
10
+ import type {
11
+ ChatItem,
12
+ ChatState,
13
+ Compacting,
14
+ ModelWait,
15
+ } from "@/lib/chat/events";
5
16
  import { formatTokens } from "@/lib/chat/context";
6
17
  import { formatElapsed } from "@/lib/chat/elapsed";
18
+ import { currentActivity, type Phase } from "@/lib/chat/progress";
19
+ import { cn } from "@/lib/utils";
7
20
  import { useNow } from "./useNow";
8
21
 
9
- type Tool = Extract<ChatItem, { kind: "tool" }>;
22
+ export { currentActivity };
10
23
 
11
- // What the turn is doing now and for how long, from its items: the latest
12
- // running step, else whether it's writing or thinking.
13
- export function currentActivity(
14
- items: ChatItem[],
15
- state: ChatState,
16
- compacting: Compacting | null = null
17
- ): { label: string; since?: number; compacting?: boolean } | null {
18
- if (state === "waiting") return { label: "Waiting for your answer" };
19
- if (state !== "running") return null;
20
- if (compacting)
21
- return {
22
- label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
23
- since: compacting.since,
24
- compacting: true,
25
- };
26
- const lastUser = items.findLast((i) => i.kind === "user");
27
- const tool = items.findLast(
28
- (i): i is Tool => i.kind === "tool" && i.status === "running"
29
- );
30
- if (tool) return { label: tool.title, since: tool.createdAt };
31
- const last = items.at(-1);
32
- const writing =
33
- last && (last.kind === "assistant" || last.kind === "reasoning");
34
- return {
35
- label:
36
- writing && last.streaming
37
- ? last.kind === "assistant"
38
- ? "Writing"
39
- : "Thinking"
40
- : "Working",
41
- since: lastUser?.createdAt,
42
- };
43
- }
24
+ const PHASE: Record<Phase, string> = {
25
+ planning: "Planning",
26
+ coding: "Coding",
27
+ testing: "Testing",
28
+ error: "Error",
29
+ };
44
30
 
45
- // One line above the composer while a turn runs, so a long wait never
46
- // looks like nothing is happening.
31
+ // Up to three lines above the composer while a turn runs, so a long wait
32
+ // never looks like nothing is happening: what it's doing and for how long,
33
+ // the phase and step count, and a hint when a model call drags on.
47
34
  export function ActivityLine({
48
35
  items,
49
36
  state,
50
37
  compacting = null,
38
+ modelWait = null,
39
+ model,
51
40
  onStop,
52
41
  }: {
53
42
  items: ChatItem[];
54
43
  state: ChatState;
55
44
  compacting?: Compacting | null;
45
+ modelWait?: ModelWait | null;
46
+ model?: string;
56
47
  onStop: () => void;
57
48
  }) {
58
- const activity = currentActivity(items, state, compacting);
59
- const now = useNow(!!activity?.since);
49
+ const now = useNow(state === "running");
50
+ const activity = currentActivity(
51
+ items,
52
+ state,
53
+ compacting,
54
+ modelWait,
55
+ model,
56
+ now
57
+ );
60
58
  if (!activity) return null;
61
59
  const waiting = state === "waiting";
60
+ const failed = activity.phase === "error" && activity.kind === "waiting";
62
61
  return (
63
- <div className="text-muted-foreground flex min-h-9 items-center gap-2 px-2 text-xs">
64
- {waiting ? (
65
- <MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
66
- ) : activity.compacting ? (
67
- <Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
68
- ) : (
69
- <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
70
- )}
71
- <span className="text-foreground/80 min-w-0 flex-1 truncate">
72
- {activity.label}
73
- </span>
74
- {activity.since && (
75
- <span className="shrink-0 font-mono tabular-nums">
76
- {formatElapsed(now - activity.since)}
62
+ <div className="text-muted-foreground px-2 text-xs">
63
+ <div className="flex min-h-9 items-center gap-2">
64
+ {waiting ? (
65
+ <MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
66
+ ) : activity.compacting ? (
67
+ <Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
68
+ ) : failed ? (
69
+ <AlertTriangle className="text-destructive h-3.5 w-3.5 shrink-0" />
70
+ ) : (
71
+ <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
72
+ )}
73
+ <span className="text-foreground/80 min-w-0 flex-1 truncate">
74
+ {activity.label}
77
75
  </span>
76
+ {activity.tokens && (
77
+ <span className="shrink-0 whitespace-nowrap">
78
+ {formatTokens(activity.tokens)}+ tokens
79
+ </span>
80
+ )}
81
+ {activity.since && (
82
+ <span className="shrink-0 font-mono tabular-nums">
83
+ {formatElapsed(now - activity.since)}
84
+ </span>
85
+ )}
86
+ {!waiting && (
87
+ <button
88
+ type="button"
89
+ onClick={onStop}
90
+ aria-label="Stop"
91
+ className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
92
+ >
93
+ <Square className="h-3 w-3 fill-current" />
94
+ Stop
95
+ </button>
96
+ )}
97
+ </div>
98
+ {!waiting && activity.steps > 0 && activity.phase && (
99
+ <div className="flex items-center gap-1.5 pb-1 pl-5 text-[11px]">
100
+ <span
101
+ className={cn(
102
+ "shrink-0 font-medium",
103
+ activity.phase === "error"
104
+ ? "text-destructive"
105
+ : "text-foreground/70"
106
+ )}
107
+ >
108
+ {PHASE[activity.phase]}
109
+ </span>
110
+ <span className="shrink-0">
111
+ · {activity.steps} {activity.steps === 1 ? "step" : "steps"}
112
+ </span>
113
+ {activity.lastStep && (
114
+ <span className="min-w-0 flex-1 truncate">
115
+ · {activity.lastStep}
116
+ </span>
117
+ )}
118
+ {activity.turnSince && (
119
+ <span className="ml-auto shrink-0 pl-2 font-mono tabular-nums">
120
+ turn {formatElapsed(now - activity.turnSince)}
121
+ </span>
122
+ )}
123
+ </div>
78
124
  )}
79
- {!waiting && (
80
- <button
81
- type="button"
82
- onClick={onStop}
83
- aria-label="Stop"
84
- className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
85
- >
86
- <Square className="h-3 w-3 fill-current" />
87
- Stop
88
- </button>
125
+ {activity.hint && (
126
+ <p className="pb-1.5 pl-5 text-[11px]">{activity.hint}</p>
89
127
  )}
90
128
  </div>
91
129
  );
@@ -33,6 +33,7 @@ import { sentPrompts } from "@/lib/chat/history";
33
33
  import { quoteInsert, quoteMarkdown } from "@/lib/chat/quote";
34
34
  import { OrchestratorBar } from "@/components/Orchestrator/OrchestratorBar";
35
35
  import { ActivityLine } from "./Activity";
36
+ import { modelLabel as pickedModelLabel } from "./ModelPicker";
36
37
  import { Approval } from "./Approval";
37
38
  import { BackgroundTasks } from "./BackgroundTasks";
38
39
  import { SubagentCard } from "./Subagent";
@@ -319,6 +320,7 @@ export function ChatPanel({
319
320
  requestFiles,
320
321
  search,
321
322
  compacting,
323
+ modelWait,
322
324
  loadedFor,
323
325
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
324
326
  const startFresh = useFreshStart();
@@ -796,6 +798,8 @@ export function ChatPanel({
796
798
  items={items}
797
799
  state={state}
798
800
  compacting={compacting}
801
+ modelWait={modelWait}
802
+ model={pickedModelLabel(models, model, modelLabel)}
799
803
  onStop={interrupt}
800
804
  />
801
805
  <BackgroundTasks
@@ -0,0 +1,36 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { ToolItem } from "@/lib/chat/group";
5
+ import { ToolGroup } from "./Tools";
6
+
7
+ const tool = (over: Partial<ToolItem>): ToolItem => ({
8
+ id: "t1",
9
+ kind: "tool",
10
+ name: "Bash",
11
+ title: "npm test",
12
+ input: { command: "npm test" },
13
+ status: "running",
14
+ createdAt: Date.now(),
15
+ ...over,
16
+ });
17
+
18
+ const html = (t: ToolItem) =>
19
+ renderToStaticMarkup(createElement(ToolGroup, { tools: [t] }));
20
+
21
+ describe("a running step", () => {
22
+ it("shows its output so far without being opened", () => {
23
+ const out = html(tool({ output: "PASS a.test.ts\nRUNS b.test.ts" }));
24
+ expect(out).toContain('data-testid="tool-output"');
25
+ expect(out).toContain("RUNS b.test.ts");
26
+ });
27
+
28
+ it("shows no output block before it prints anything", () => {
29
+ expect(html(tool({}))).not.toContain("tool-output");
30
+ });
31
+
32
+ it("keeps a finished step collapsed", () => {
33
+ const out = html(tool({ status: "done", output: "ok" }));
34
+ expect(out).not.toContain("tool-output");
35
+ });
36
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useEffect } from "react";
3
+ import { createContext, useContext, useEffect, useRef } from "react";
4
4
  import { Check, ChevronRight, Layers, Loader2, Square, X } from "lucide-react";
5
5
  import type { FileDiff, ToolBody } from "@/lib/chat/events";
6
6
  import type { ToolItem } from "@/lib/chat/group";
@@ -111,6 +111,36 @@ export function ToolPreview({
111
111
  );
112
112
  }
113
113
 
114
+ // A step's output. While it runs the view follows the end, unless the
115
+ // reader scrolled up to read.
116
+ export function LiveOutput({
117
+ text,
118
+ running,
119
+ }: {
120
+ text: string;
121
+ running: boolean;
122
+ }) {
123
+ const ref = useRef<HTMLPreElement>(null);
124
+ const pinned = useRef(true);
125
+ useEffect(() => {
126
+ const el = ref.current;
127
+ if (running && el && pinned.current) el.scrollTop = el.scrollHeight;
128
+ }, [text, running]);
129
+ return (
130
+ <pre
131
+ ref={ref}
132
+ data-testid="tool-output"
133
+ onScroll={(e) => {
134
+ const el = e.currentTarget;
135
+ pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
136
+ }}
137
+ className="text-muted-foreground max-h-64 overflow-auto rounded-lg px-2 font-mono text-[11px] whitespace-pre-wrap"
138
+ >
139
+ {text}
140
+ </pre>
141
+ );
142
+ }
143
+
114
144
  function ToolDetail({ tool }: { tool: ToolItem }) {
115
145
  const body = useToolBody(tool);
116
146
  if (body === undefined && tool.hasDiff)
@@ -123,9 +153,7 @@ function ToolDetail({ tool }: { tool: ToolItem }) {
123
153
  <div className="space-y-2 pb-2 pl-6">
124
154
  <ToolPreview name={tool.name} input={tool.input} diff={body?.diff} />
125
155
  {body?.output && !body.diff && (
126
- <pre className="text-muted-foreground max-h-64 overflow-auto rounded-lg px-2 font-mono text-[11px] whitespace-pre-wrap">
127
- {body.output}
128
- </pre>
156
+ <LiveOutput text={body.output} running={tool.status === "running"} />
129
157
  )}
130
158
  {body === undefined && tool.hasOutput && (
131
159
  <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" />
@@ -148,6 +176,10 @@ function Elapsed({ tool }: { tool: ToolItem }) {
148
176
  );
149
177
  }
150
178
 
179
+ // A running step that has printed something: its output shows as it comes.
180
+ const streaming = (tool: ToolItem) =>
181
+ tool.status === "running" && !!tool.output && !tool.deferred;
182
+
151
183
  function ToolRow({ tool }: { tool: ToolItem }) {
152
184
  const [open, setOpen] = useRowState(
153
185
  `tool:${tool.id}`,
@@ -186,7 +218,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
186
218
  <BackgroundButton tool={tool} />
187
219
  <BookmarkButton itemId={tool.id} />
188
220
  </div>
189
- {open && <ToolDetail tool={tool} />}
221
+ {(open || streaming(tool)) && <ToolDetail tool={tool} />}
190
222
  </div>
191
223
  );
192
224
  }
@@ -244,6 +276,11 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
244
276
  {!open && !running && (
245
277
  <StepOutcomes tools={tools} onOpen={() => setOpen(true)} />
246
278
  )}
279
+ {!open && streaming(latest) && (
280
+ <div className="pb-1">
281
+ <ToolDetail tool={latest} />
282
+ </div>
283
+ )}
247
284
  {open && (
248
285
  <div className="pb-1">
249
286
  {tools.map((t) => (
@@ -16,6 +16,7 @@ import type {
16
16
  ChatServerMessage,
17
17
  ChatState,
18
18
  Compacting,
19
+ ModelWait,
19
20
  FileSuggestion,
20
21
  QueuedMessage,
21
22
  ToolBody,
@@ -164,6 +165,7 @@ export function useChat(
164
165
  }, []);
165
166
  const [state, setState] = useState<ChatState>("idle");
166
167
  const [compacting, setCompacting] = useState<Compacting | null>(null);
168
+ const [modelWait, setModelWait] = useState<ModelWait | null>(null);
167
169
  const [connected, setConnected] = useState(false);
168
170
  // The chat whose snapshot the items are, once one has landed.
169
171
  const [loadedFor, setLoadedFor] = useState<string | null>(null);
@@ -271,6 +273,7 @@ export function useChat(
271
273
  setLoadingOlder(false);
272
274
  setState(m.state);
273
275
  setCompacting(m.compacting ?? null);
276
+ setModelWait(m.modelWait ?? null);
274
277
  setQueue(m.queue ?? []);
275
278
  setSuggestion(m.suggestion ?? null);
276
279
  setLoadedFor(sessionId);
@@ -295,9 +298,14 @@ export function useChat(
295
298
  searchRequests.current.delete(m.reqId);
296
299
  } else if (m.type === "compacting") {
297
300
  setCompacting(m.compacting);
301
+ } else if (m.type === "model_wait") {
302
+ setModelWait(m.wait);
298
303
  } else if (m.type === "state") {
299
304
  setState(m.state);
300
- if (m.state === "idle" || m.state === "error") setCompacting(null);
305
+ if (m.state === "idle" || m.state === "error") {
306
+ setCompacting(null);
307
+ setModelWait(null);
308
+ }
301
309
  } else if (m.type === "capabilities") {
302
310
  setRefreshingCommands(false);
303
311
  setCommands(m.commands);
@@ -596,6 +604,7 @@ export function useChat(
596
604
  pinToolBody,
597
605
  state,
598
606
  compacting,
607
+ modelWait,
599
608
  connected,
600
609
  commands,
601
610
  refreshCommands,
@@ -233,6 +233,16 @@ export class ClaudeMapper {
233
233
  case "task_progress":
234
234
  case "task_notification":
235
235
  return this.task(m);
236
+ case "api_retry": {
237
+ const r = m as { attempt?: number; error_status?: number | null };
238
+ return [
239
+ {
240
+ type: "model_retry",
241
+ attempt: Number(r.attempt) || 1,
242
+ message: r.error_status ? `HTTP ${r.error_status}` : undefined,
243
+ },
244
+ ];
245
+ }
236
246
  case "status": {
237
247
  // "compacting" while the context is summarised, null once it's done.
238
248
  const status = (m as { status?: string | null }).status;
@@ -454,5 +454,13 @@ export function usageEvents(
454
454
  ? "review"
455
455
  : "sub";
456
456
  const call = callFromAssistant(m.message, kind, agentType, at);
457
- return call ? [{ type: "call_usage", call }] : [];
457
+ if (!call) return [];
458
+ const prompt =
459
+ call.input + call.cacheRead + call.cacheWrite5m + call.cacheWrite1h;
460
+ return [
461
+ { type: "call_usage", call },
462
+ ...(kind === "main" && prompt > 0
463
+ ? [{ type: "prompt_tokens" as const, tokens: prompt }]
464
+ : []),
465
+ ];
458
466
  }