@saadnvd1/agent-os 0.3.737 → 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.
- package/components/Chat/Activity.test.ts +2 -1
- package/components/Chat/Activity.tsx +102 -64
- package/components/Chat/ChatPanel.tsx +16 -3
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Messages.test.ts +13 -1
- package/components/Chat/Messages.tsx +19 -2
- package/components/Chat/StepOutcomes.tsx +66 -0
- package/components/Chat/Tools.test.ts +52 -0
- package/components/Chat/Tools.tsx +63 -9
- package/data/chat/useChat.ts +10 -1
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/opencode-config.ts +111 -0
- package/lib/chat/drivers/opencode-mapper.ts +125 -13
- package/lib/chat/drivers/opencode-plugin.ts +78 -0
- package/lib/chat/drivers/opencode-server.ts +7 -2
- package/lib/chat/drivers/opencode.ts +108 -9
- package/lib/chat/drivers/pi-edit-guard.ts +87 -0
- package/lib/chat/drivers/pi-mapper.ts +91 -2
- package/lib/chat/drivers/pi.ts +95 -4
- package/lib/chat/drivers/transient-retry.ts +87 -0
- package/lib/chat/drivers/turn-items.ts +74 -1
- package/lib/chat/drivers/turn-outcome.ts +73 -1
- package/lib/chat/events.ts +17 -1
- package/lib/chat/group.ts +23 -3
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/page.ts +3 -0
- package/lib/chat/progress.ts +169 -0
- package/lib/chat/registry.ts +5 -0
- package/lib/chat/runner-watch.ts +68 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tool-outcome.ts +45 -0
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- package/scripts/screenshots/seed.ts +9 -0
package/lib/chat/events.ts
CHANGED
|
@@ -185,6 +185,8 @@ export type ChatItem =
|
|
|
185
185
|
input: unknown;
|
|
186
186
|
status: ToolStatus;
|
|
187
187
|
output?: string;
|
|
188
|
+
// How it ended ("exit 1", "HTTP 404"), kept when the output is deferred.
|
|
189
|
+
outcome?: string;
|
|
188
190
|
diff?: FileDiff;
|
|
189
191
|
// When it finished, to show how long a step took.
|
|
190
192
|
endedAt?: number;
|
|
@@ -320,6 +322,14 @@ export interface Compacting {
|
|
|
320
322
|
before?: number;
|
|
321
323
|
}
|
|
322
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
|
+
|
|
323
333
|
export interface UndoPreview {
|
|
324
334
|
canUndo: boolean;
|
|
325
335
|
error?: string;
|
|
@@ -345,7 +355,11 @@ export type DriverEvent =
|
|
|
345
355
|
| { type: "at_rest" } // it has run every message it was given
|
|
346
356
|
| { type: "state"; state: ChatState }
|
|
347
357
|
// The agent started (true) or finished (false) compacting its context.
|
|
348
|
-
| { 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 };
|
|
349
363
|
|
|
350
364
|
// A tool call's output and diff, loaded when it's opened.
|
|
351
365
|
export interface ToolBody {
|
|
@@ -366,6 +380,7 @@ export type ChatServerMessage =
|
|
|
366
380
|
tasks: ChatItem[];
|
|
367
381
|
state: ChatState;
|
|
368
382
|
compacting?: Compacting | null;
|
|
383
|
+
modelWait?: ModelWait | null;
|
|
369
384
|
queue: QueuedMessage[];
|
|
370
385
|
suggestion: string | null;
|
|
371
386
|
}
|
|
@@ -391,6 +406,7 @@ export type ChatServerMessage =
|
|
|
391
406
|
| { type: "delta"; id: string; text: string }
|
|
392
407
|
| { type: "state"; state: ChatState }
|
|
393
408
|
| { type: "compacting"; compacting: Compacting | null }
|
|
409
|
+
| { type: "model_wait"; wait: ModelWait | null }
|
|
394
410
|
| {
|
|
395
411
|
type: "capabilities";
|
|
396
412
|
commands: ChatCommand[];
|
package/lib/chat/group.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ChatItem } from "./events";
|
|
2
2
|
import { readingOrder } from "./reading-order";
|
|
3
|
+
import { stepCounts, type StepCounts } from "./tool-outcome";
|
|
3
4
|
import { eventTurnAt, type EventTurn } from "./turns";
|
|
4
5
|
|
|
5
6
|
export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
|
|
6
7
|
export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
|
|
7
8
|
|
|
8
9
|
export type TimelineBlock =
|
|
9
|
-
|
|
10
|
+
// `steps` rides on the end of a stopped or failed turn: what ran in it.
|
|
11
|
+
| { type: "item"; item: ChatItem; steps?: StepCounts }
|
|
10
12
|
| { type: "tools"; id: string; tools: ToolItem[] }
|
|
11
13
|
| { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
|
|
12
14
|
// Consecutive turns an event started, with the reply to each.
|
|
@@ -30,6 +32,8 @@ export function groupTimeline(
|
|
|
30
32
|
const items = opts.foldEvents ? readingOrder(all) : all;
|
|
31
33
|
const blocks: TimelineBlock[] = [];
|
|
32
34
|
let tools: ToolItem[] = [];
|
|
35
|
+
// Every tool call since the last turn ended, grouped or not.
|
|
36
|
+
let turnTools: ToolItem[] = [];
|
|
33
37
|
const flush = () => {
|
|
34
38
|
if (tools.length) blocks.push({ type: "tools", id: tools[0].id, tools });
|
|
35
39
|
tools = [];
|
|
@@ -74,8 +78,10 @@ export function groupTimeline(
|
|
|
74
78
|
i = end;
|
|
75
79
|
} else if (item.kind === "tool" && STANDALONE_TOOLS.has(item.name)) {
|
|
76
80
|
flush();
|
|
81
|
+
turnTools.push(item);
|
|
77
82
|
blocks.push({ type: "item", item });
|
|
78
83
|
} else if (item.kind === "tool") {
|
|
84
|
+
turnTools.push(item);
|
|
79
85
|
tools.push(item);
|
|
80
86
|
} else if (item.kind === "reasoning" && tools.length) {
|
|
81
87
|
continue;
|
|
@@ -91,7 +97,15 @@ export function groupTimeline(
|
|
|
91
97
|
continue;
|
|
92
98
|
} else {
|
|
93
99
|
flush();
|
|
94
|
-
|
|
100
|
+
const cut =
|
|
101
|
+
item.kind === "turn_end" &&
|
|
102
|
+
(item.interrupted || item.ended?.reason === "error");
|
|
103
|
+
blocks.push(
|
|
104
|
+
cut && turnTools.length
|
|
105
|
+
? { type: "item", item, steps: stepCounts(turnTools) }
|
|
106
|
+
: { type: "item", item }
|
|
107
|
+
);
|
|
108
|
+
if (item.kind === "turn_end") turnTools = [];
|
|
95
109
|
}
|
|
96
110
|
}
|
|
97
111
|
flush();
|
|
@@ -123,7 +137,13 @@ function undoneRanges(items: ChatItem[]): Map<number, number> {
|
|
|
123
137
|
// until they change, so a streamed delta re-renders only its own block.
|
|
124
138
|
export function sameBlock(a: TimelineBlock, b: TimelineBlock): boolean {
|
|
125
139
|
if (a === b) return true;
|
|
126
|
-
if (a.type === "item" && b.type === "item")
|
|
140
|
+
if (a.type === "item" && b.type === "item")
|
|
141
|
+
return (
|
|
142
|
+
a.item === b.item &&
|
|
143
|
+
a.steps?.total === b.steps?.total &&
|
|
144
|
+
a.steps?.ok === b.steps?.ok &&
|
|
145
|
+
a.steps?.failed === b.steps?.failed
|
|
146
|
+
);
|
|
127
147
|
if (a.type === "tools" && b.type === "tools")
|
|
128
148
|
return sameItems(a.tools, b.tools);
|
|
129
149
|
if (a.type === "undone" && b.type === "undone")
|
|
@@ -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
|
+
}
|
package/lib/chat/page.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { db } from "../db";
|
|
2
2
|
import type { ChatItem, ToolBody } from "./events";
|
|
3
|
+
import { toolCode } from "./tool-outcome";
|
|
3
4
|
|
|
4
5
|
// A conversation opens on its latest items and pages older ones in as the
|
|
5
6
|
// reader scrolls up, so a long one costs what's on screen, not its history.
|
|
@@ -23,8 +24,10 @@ export function lighten(item: ChatItem): ChatItem {
|
|
|
23
24
|
if (item.kind !== "tool" || (item.output === undefined && !item.diff))
|
|
24
25
|
return item;
|
|
25
26
|
const { output, diff, ...rest } = item;
|
|
27
|
+
const outcome = toolCode(output);
|
|
26
28
|
return {
|
|
27
29
|
...rest,
|
|
30
|
+
...(outcome ? { outcome } : {}),
|
|
28
31
|
deferred: true,
|
|
29
32
|
...(diff ? { hasDiff: true } : {}),
|
|
30
33
|
...(output ? { hasOutput: true } : {}),
|
|
@@ -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
|
+
}
|
package/lib/chat/registry.ts
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ChatContext, ChatItem } from "./events";
|
|
2
|
+
import { getSession, registry, type Listener } from "./registry";
|
|
3
|
+
import { releaseUnwatchedSoon, sendCapabilities } from "./settings";
|
|
4
|
+
import { recentTasks, settle } from "./store";
|
|
5
|
+
import { readPage, toolBody, type ItemPage } from "./page";
|
|
6
|
+
import { listQueue } from "./queued";
|
|
7
|
+
import { runningWorkers } from "./worker/client";
|
|
8
|
+
|
|
9
|
+
function savedContext(sessionId: string): ChatContext | null {
|
|
10
|
+
const raw = getSession(sessionId).chat_context;
|
|
11
|
+
if (!raw) return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(raw) as ChatContext;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// What a watcher is sent of stored items: the live text of what's still
|
|
20
|
+
// streaming, or with no conversation running, nothing left running.
|
|
21
|
+
function present(sessionId: string, items: ChatItem[]): ChatItem[] {
|
|
22
|
+
const live = registry.live.get(sessionId);
|
|
23
|
+
if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
|
|
24
|
+
return runningWorkers().includes(sessionId) ? items : settle(items);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Snapshot then live updates. Returns the unsubscribe function. Every
|
|
28
|
+
// client gets the latest page and asks for older ones.
|
|
29
|
+
export function watchChat(sessionId: string, listener: Listener): () => void {
|
|
30
|
+
const live = registry.live.get(sessionId);
|
|
31
|
+
const page = readPage(sessionId);
|
|
32
|
+
const onPage = new Set(page.items.map((i) => i.id));
|
|
33
|
+
// The composer lists background work still going or just done, even when
|
|
34
|
+
// it started before the page.
|
|
35
|
+
const recent = Date.now() - 60 * 60 * 1000;
|
|
36
|
+
const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
|
|
37
|
+
listener({
|
|
38
|
+
type: "snapshot",
|
|
39
|
+
items: present(sessionId, page.items),
|
|
40
|
+
cursor: page.cursor,
|
|
41
|
+
hasMore: page.hasMore,
|
|
42
|
+
tasks: present(sessionId, tasks),
|
|
43
|
+
state: live?.state ?? "idle",
|
|
44
|
+
compacting: live?.compacting ?? null,
|
|
45
|
+
modelWait: live?.modelWait ?? null,
|
|
46
|
+
queue: listQueue(sessionId),
|
|
47
|
+
suggestion: getSession(sessionId).chat_suggestion ?? null,
|
|
48
|
+
});
|
|
49
|
+
listener({ type: "context", context: savedContext(sessionId) });
|
|
50
|
+
sendCapabilities(sessionId, listener).catch((error) =>
|
|
51
|
+
console.error(`[chat ${sessionId}] capabilities not sent:`, error)
|
|
52
|
+
);
|
|
53
|
+
let set = registry.listeners.get(sessionId);
|
|
54
|
+
if (!set) registry.listeners.set(sessionId, (set = new Set()));
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return () => {
|
|
57
|
+
set?.delete(listener);
|
|
58
|
+
releaseUnwatchedSoon();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The page before `before`, as the reader scrolls up.
|
|
63
|
+
export function chatHistory(sessionId: string, before: number): ItemPage {
|
|
64
|
+
const page = readPage(sessionId, before);
|
|
65
|
+
return { ...page, items: present(sessionId, page.items) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const chatToolBody = toolBody;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -16,9 +16,9 @@ import { randomUUID } from "crypto";
|
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { db, type Session } from "../db";
|
|
18
18
|
import type { CompactFirst } from "./stale";
|
|
19
|
+
import { trackModelWait } from "./model-wait-live";
|
|
19
20
|
import type {
|
|
20
21
|
ApprovalDecision,
|
|
21
|
-
ChatContext,
|
|
22
22
|
ChatFile,
|
|
23
23
|
ChatImage,
|
|
24
24
|
ChatServerMessage,
|
|
@@ -34,26 +34,17 @@ import {
|
|
|
34
34
|
type Listener,
|
|
35
35
|
type Live,
|
|
36
36
|
} from "./registry";
|
|
37
|
-
import {
|
|
38
|
-
capsKey,
|
|
39
|
-
emitCapabilities,
|
|
40
|
-
releaseUnwatchedSoon,
|
|
41
|
-
sendCapabilities,
|
|
42
|
-
setChatPlan,
|
|
43
|
-
} from "./settings";
|
|
37
|
+
import { capsKey, emitCapabilities, setChatPlan } from "./settings";
|
|
44
38
|
import {
|
|
45
39
|
endOrphanedTasks,
|
|
46
40
|
endRunningTasks,
|
|
47
41
|
getItem,
|
|
48
42
|
hasItem,
|
|
49
43
|
itemsOfKind,
|
|
50
|
-
recentTasks,
|
|
51
44
|
sessionsWithRunningTasks,
|
|
52
45
|
saveItem,
|
|
53
|
-
settle,
|
|
54
46
|
} from "./store";
|
|
55
47
|
import { noteToolResult } from "../health/env/recordHit";
|
|
56
|
-
import { readPage, toolBody, type ItemPage } from "./page";
|
|
57
48
|
import {
|
|
58
49
|
deleteQueued,
|
|
59
50
|
editQueued,
|
|
@@ -101,16 +92,6 @@ export {
|
|
|
101
92
|
setChatPlan,
|
|
102
93
|
} from "./settings";
|
|
103
94
|
|
|
104
|
-
function savedContext(sessionId: string): ChatContext | null {
|
|
105
|
-
const raw = getSession(sessionId).chat_context;
|
|
106
|
-
if (!raw) return null;
|
|
107
|
-
try {
|
|
108
|
-
return JSON.parse(raw) as ChatContext;
|
|
109
|
-
} catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
95
|
const CARRY_OUT = "Carry out the plan.";
|
|
115
96
|
|
|
116
97
|
// Leaves plan mode and starts the plan. The message's id comes from the
|
|
@@ -192,6 +173,7 @@ function emitEnded(
|
|
|
192
173
|
|
|
193
174
|
function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
194
175
|
track(live, e);
|
|
176
|
+
if (trackModelWait(sessionId, live, e)) return;
|
|
195
177
|
if (e.type === "item" && e.item.kind === "tool")
|
|
196
178
|
noteToolResult(sessionId, e.item);
|
|
197
179
|
if (
|
|
@@ -968,55 +950,7 @@ export function chatTaskOutput(
|
|
|
968
950
|
return taskOutputTail(taskId, task.outputFile);
|
|
969
951
|
}
|
|
970
952
|
|
|
971
|
-
|
|
972
|
-
// streaming, or with no conversation running, nothing left running.
|
|
973
|
-
function present(sessionId: string, items: ChatItem[]): ChatItem[] {
|
|
974
|
-
const live = registry.live.get(sessionId);
|
|
975
|
-
if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
|
|
976
|
-
return runningWorkers().includes(sessionId) ? items : settle(items);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Snapshot then live updates. Returns the unsubscribe function. Every
|
|
980
|
-
// client gets the latest page and asks for older ones.
|
|
981
|
-
export function watchChat(sessionId: string, listener: Listener): () => void {
|
|
982
|
-
const live = registry.live.get(sessionId);
|
|
983
|
-
const page = readPage(sessionId);
|
|
984
|
-
const onPage = new Set(page.items.map((i) => i.id));
|
|
985
|
-
// The composer lists background work still going or just done, even when
|
|
986
|
-
// it started before the page.
|
|
987
|
-
const recent = Date.now() - 60 * 60 * 1000;
|
|
988
|
-
const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
|
|
989
|
-
listener({
|
|
990
|
-
type: "snapshot",
|
|
991
|
-
items: present(sessionId, page.items),
|
|
992
|
-
cursor: page.cursor,
|
|
993
|
-
hasMore: page.hasMore,
|
|
994
|
-
tasks: present(sessionId, tasks),
|
|
995
|
-
state: live?.state ?? "idle",
|
|
996
|
-
compacting: live?.compacting ?? null,
|
|
997
|
-
queue: listQueue(sessionId),
|
|
998
|
-
suggestion: getSession(sessionId).chat_suggestion ?? null,
|
|
999
|
-
});
|
|
1000
|
-
listener({ type: "context", context: savedContext(sessionId) });
|
|
1001
|
-
sendCapabilities(sessionId, listener).catch((error) =>
|
|
1002
|
-
console.error(`[chat ${sessionId}] capabilities not sent:`, error)
|
|
1003
|
-
);
|
|
1004
|
-
let set = registry.listeners.get(sessionId);
|
|
1005
|
-
if (!set) registry.listeners.set(sessionId, (set = new Set()));
|
|
1006
|
-
set.add(listener);
|
|
1007
|
-
return () => {
|
|
1008
|
-
set?.delete(listener);
|
|
1009
|
-
releaseUnwatchedSoon();
|
|
1010
|
-
};
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// The page before `before`, as the reader scrolls up.
|
|
1014
|
-
export function chatHistory(sessionId: string, before: number): ItemPage {
|
|
1015
|
-
const page = readPage(sessionId, before);
|
|
1016
|
-
return { ...page, items: present(sessionId, page.items) };
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
export const chatToolBody = toolBody;
|
|
953
|
+
export { chatHistory, chatToolBody, watchChat } from "./runner-watch";
|
|
1020
954
|
|
|
1021
955
|
// After a restart: reconnect to every worker still running a conversation,
|
|
1022
956
|
// and send the queues of conversations whose worker is gone.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ToolItem } from "./group";
|
|
2
|
+
|
|
3
|
+
const EDGE = 4096;
|
|
4
|
+
|
|
5
|
+
// A short label for how a command ended, read from its output: the last HTTP
|
|
6
|
+
// status line (a redirect chain reports where it landed) or an exit code.
|
|
7
|
+
export function toolCode(output?: string): string | undefined {
|
|
8
|
+
if (!output) return undefined;
|
|
9
|
+
const text =
|
|
10
|
+
output.length > EDGE * 2
|
|
11
|
+
? `${output.slice(0, EDGE)}\n${output.slice(-EDGE)}`
|
|
12
|
+
: output;
|
|
13
|
+
const http = [...text.matchAll(/^HTTP\/[\d.]+ (\d{3})/gm)].pop();
|
|
14
|
+
if (http) return `HTTP ${http[1]}`;
|
|
15
|
+
const exit = [
|
|
16
|
+
...text.matchAll(
|
|
17
|
+
/(?:^Exit code |Command exited with code |exit (?:code|status)[:= ]+)(\d+)/gim
|
|
18
|
+
),
|
|
19
|
+
].pop();
|
|
20
|
+
return exit ? `exit ${exit[1]}` : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const toolOutcome = (tool: ToolItem): string | undefined =>
|
|
24
|
+
tool.outcome ?? toolCode(tool.output);
|
|
25
|
+
|
|
26
|
+
export interface StepCounts {
|
|
27
|
+
total: number;
|
|
28
|
+
ok: number;
|
|
29
|
+
failed: number;
|
|
30
|
+
// Never finished: the turn stopped first.
|
|
31
|
+
stopped: number;
|
|
32
|
+
running: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stepCounts(tools: ToolItem[]): StepCounts {
|
|
36
|
+
const n = (s: ToolItem["status"]) =>
|
|
37
|
+
tools.filter((t) => t.status === s).length;
|
|
38
|
+
return {
|
|
39
|
+
total: tools.length,
|
|
40
|
+
ok: n("done"),
|
|
41
|
+
failed: n("error"),
|
|
42
|
+
stopped: n("stopped"),
|
|
43
|
+
running: n("running"),
|
|
44
|
+
};
|
|
45
|
+
}
|
package/lib/chat/tools.ts
CHANGED
|
@@ -81,3 +81,17 @@ export function clipOutput(text: string): string {
|
|
|
81
81
|
? `${text.slice(0, MAX_TOOL_OUTPUT)}\n… (${text.length - MAX_TOOL_OUTPUT} more characters)`
|
|
82
82
|
: text;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
export const LIVE_OUTPUT_LINES = 200;
|
|
86
|
+
|
|
87
|
+
// A running step's output so far: the last lines, never more than a
|
|
88
|
+
// finished step keeps. The end of a long build is what the reader wants.
|
|
89
|
+
export function tailOutput(text: string): string {
|
|
90
|
+
const lines = text.split("\n");
|
|
91
|
+
let tail =
|
|
92
|
+
lines.length > LIVE_OUTPUT_LINES
|
|
93
|
+
? lines.slice(-LIVE_OUTPUT_LINES).join("\n")
|
|
94
|
+
: text;
|
|
95
|
+
if (tail.length > MAX_TOOL_OUTPUT) tail = tail.slice(-MAX_TOOL_OUTPUT);
|
|
96
|
+
return tail === text ? tail : `…\n${tail}`;
|
|
97
|
+
}
|