@saadnvd1/agent-os 0.3.738 → 0.3.743
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 +4 -0
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Tools.tsx +42 -5
- package/data/chat/useChat.ts +10 -1
- package/lib/chat/demo.ts +42 -4
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/demo-script.ts +148 -0
- package/lib/chat/drivers/demo.ts +21 -4
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/index.ts +2 -2
- 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 +74 -1
- package/lib/chat/drivers/pi.ts +82 -3
- 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 +48 -1
- package/lib/chat/events.ts +15 -1
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/progress.ts +169 -0
- package/lib/chat/registry.ts +5 -0
- package/lib/chat/runner-watch.ts +71 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tools.ts +14 -0
- package/lib/security/demo.ts +8 -0
- package/lib/tasks/move-testing.ts +6 -1
- package/package.json +1 -1
- package/scripts/screenshots/run.ts +27 -3
- package/scripts/screenshots/running-turn.ts +54 -0
- package/scripts/screenshots/seed.ts +15 -3
- package/scripts/screenshots/shoot.ts +3 -1
- package/scripts/screenshots/shots.ts +5 -0
- package/server.ts +10 -2
|
@@ -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
|
-
).
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
22
|
+
export { currentActivity };
|
|
10
23
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
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
|
|
59
|
-
const
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
{
|
|
80
|
-
<
|
|
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
|
-
<
|
|
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) => (
|
package/data/chat/useChat.ts
CHANGED
|
@@ -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")
|
|
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,
|
package/lib/chat/demo.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { clientSend } from "./client-send";
|
|
9
9
|
import type { ChatConversation } from "./driver";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ChatClientMessage,
|
|
12
|
+
ChatServerMessage,
|
|
13
|
+
ChatState,
|
|
14
|
+
ModelWait,
|
|
15
|
+
} from "./events";
|
|
11
16
|
import { demoConversation } from "./drivers/demo";
|
|
17
|
+
import { DEMO_SCRIPT_PROMPT, parseDemoScript } from "./drivers/demo-script";
|
|
18
|
+
import { ModelWaitTracker } from "./model-wait";
|
|
19
|
+
import type { WorkerEvent } from "./worker/protocol";
|
|
12
20
|
import { emit, getSession } from "./registry";
|
|
13
|
-
import { saveItem } from "./store";
|
|
21
|
+
import { deleteItems, saveItem } from "./store";
|
|
14
22
|
import { DEMO_REFUSAL, DEMO_VISITOR_TEXT } from "../security/demo";
|
|
15
23
|
|
|
16
24
|
const conversations = new Map<string, ChatConversation>();
|
|
@@ -21,13 +29,34 @@ export const setDemoReplyStep = (ms: number) => (stepMs = ms);
|
|
|
21
29
|
export const MAX_SENDS = 50;
|
|
22
30
|
const sends = new Map<string, number>();
|
|
23
31
|
|
|
32
|
+
// What a demo chat is doing, for a watcher that connects mid-turn (the stored
|
|
33
|
+
// items alone would show a running step as stopped).
|
|
34
|
+
const live = new Map<string, { state: ChatState; wait: ModelWait | null }>();
|
|
35
|
+
|
|
36
|
+
/** The demo chat's running turn, or null with none. */
|
|
37
|
+
export function demoLive(sessionId: string) {
|
|
38
|
+
return live.get(sessionId) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
function conversation(sessionId: string): ChatConversation {
|
|
25
42
|
let c = conversations.get(sessionId);
|
|
26
43
|
if (c) return c;
|
|
27
44
|
c = demoConversation(stepMs);
|
|
28
45
|
conversations.set(sessionId, c);
|
|
46
|
+
let tracker = new ModelWaitTracker();
|
|
29
47
|
void (async () => {
|
|
30
48
|
for await (const e of c.events) {
|
|
49
|
+
// Each turn follows its own waits, whatever the last one left.
|
|
50
|
+
if (e.type === "state" && e.state === "running")
|
|
51
|
+
tracker = new ModelWaitTracker();
|
|
52
|
+
if (tracker.on(e as WorkerEvent))
|
|
53
|
+
emit(sessionId, { type: "model_wait", wait: tracker.wait });
|
|
54
|
+
if (e.type === "state") {
|
|
55
|
+
if (e.state === "idle") live.delete(sessionId);
|
|
56
|
+
else live.set(sessionId, { state: e.state, wait: tracker.wait });
|
|
57
|
+
}
|
|
58
|
+
const state = live.get(sessionId);
|
|
59
|
+
if (state) state.wait = tracker.wait;
|
|
31
60
|
if (e.type === "item") {
|
|
32
61
|
if (!("streaming" in e.item && e.item.streaming))
|
|
33
62
|
saveItem(sessionId, e.item);
|
|
@@ -49,16 +78,24 @@ export function sendDemoChat(
|
|
|
49
78
|
const count = sends.get(sessionId) ?? 0;
|
|
50
79
|
if (count >= MAX_SENDS) throw new Error(DEMO_REFUSAL);
|
|
51
80
|
sends.set(sessionId, count + 1);
|
|
81
|
+
// The scripted turn (drivers/demo-script) is canned text a visitor can only
|
|
82
|
+
// trigger, never change, so its prompt is shown as is.
|
|
83
|
+
const script = parseDemoScript(trimmed) !== null;
|
|
84
|
+
// A scripted turn starts a clean chat, so screenshots never show the last.
|
|
85
|
+
if (script) {
|
|
86
|
+
deleteItems(sessionId);
|
|
87
|
+
live.delete(sessionId);
|
|
88
|
+
}
|
|
52
89
|
const item = {
|
|
53
90
|
id,
|
|
54
91
|
kind: "user" as const,
|
|
55
|
-
text: DEMO_VISITOR_TEXT,
|
|
92
|
+
text: script ? DEMO_SCRIPT_PROMPT : DEMO_VISITOR_TEXT,
|
|
56
93
|
tagged: true as const,
|
|
57
94
|
createdAt: Date.now(),
|
|
58
95
|
};
|
|
59
96
|
saveItem(sessionId, item);
|
|
60
97
|
emit(sessionId, { type: "item", item });
|
|
61
|
-
conversation(sessionId).send(DEMO_VISITOR_TEXT);
|
|
98
|
+
conversation(sessionId).send(script ? trimmed : DEMO_VISITOR_TEXT);
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
/** After a re-seed: stub conversations and send counts start over. */
|
|
@@ -69,6 +106,7 @@ export function resetDemoChat(): void {
|
|
|
69
106
|
emit(sessionId, { type: "state", state: "idle" });
|
|
70
107
|
}
|
|
71
108
|
conversations.clear();
|
|
109
|
+
live.clear();
|
|
72
110
|
sends.clear();
|
|
73
111
|
}
|
|
74
112
|
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demo's scripted running turn: typed into a demo chat, it holds a state
|
|
3
|
+
* a screenshot needs (a tool streaming partial output, a long wait on the
|
|
4
|
+
* model, a retry) for a set time, then finishes. Demo only: the demo driver
|
|
5
|
+
* is the one place that reads it (AGENTOS_DEMO=1; no real session gets here).
|
|
6
|
+
*
|
|
7
|
+
* demo: running turn [step|wait|retry] [hold=<seconds>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DriverEvent } from "../events";
|
|
11
|
+
|
|
12
|
+
export type DemoPhase = "step" | "wait" | "retry";
|
|
13
|
+
|
|
14
|
+
export interface DemoScript {
|
|
15
|
+
// Hold only this state; none runs all three, one after the other.
|
|
16
|
+
phase?: DemoPhase;
|
|
17
|
+
holdMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_HOLD_S = 30;
|
|
21
|
+
export const MAX_HOLD_S = 120;
|
|
22
|
+
const TRIGGER = /^demo: running turn(?: (step|wait|retry))?(?: hold=(\d+))?$/;
|
|
23
|
+
|
|
24
|
+
export function parseDemoScript(text: string): DemoScript | null {
|
|
25
|
+
const m = TRIGGER.exec(text.trim());
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const seconds = m[2] === undefined ? DEFAULT_HOLD_S : Number(m[2]);
|
|
28
|
+
return {
|
|
29
|
+
phase: m[1] as DemoPhase | undefined,
|
|
30
|
+
holdMs: Math.min(seconds, MAX_HOLD_S) * 1000,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEMO_SCRIPT_PROMPT =
|
|
35
|
+
"Run the checkout tests and fix whatever fails.";
|
|
36
|
+
|
|
37
|
+
const COMMAND = "npx vitest run src/checkout";
|
|
38
|
+
const LINES = [
|
|
39
|
+
" RUN v3.2.4 ~/code/storefront",
|
|
40
|
+
" ✓ src/checkout/discount.test.ts (6 tests) 12ms",
|
|
41
|
+
" ✓ src/checkout/tax.test.ts (9 tests) 18ms",
|
|
42
|
+
" ✓ src/checkout/cart.test.ts (14 tests) 41ms",
|
|
43
|
+
" ✓ src/checkout/shipping.test.ts (8 tests) 22ms",
|
|
44
|
+
" ✓ src/checkout/coupons.test.ts (11 tests) 35ms",
|
|
45
|
+
" ✓ src/checkout/currency.test.ts (7 tests) 9ms",
|
|
46
|
+
" ❯ src/checkout/totals.test.ts (12 tests | 1 running)",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export interface ScriptRun {
|
|
50
|
+
push: (e: DriverEvent) => void;
|
|
51
|
+
stopped: () => boolean;
|
|
52
|
+
sleep: (ms: number) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Waits `ms`, in slices so an interrupt ends it.
|
|
56
|
+
async function hold(run: ScriptRun, ms: number) {
|
|
57
|
+
for (let left = ms; left > 0 && !run.stopped(); left -= 100)
|
|
58
|
+
await run.sleep(Math.min(100, left));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runningStep(run: ScriptRun, id: string, holdMs: number) {
|
|
62
|
+
const tool = {
|
|
63
|
+
id,
|
|
64
|
+
kind: "tool" as const,
|
|
65
|
+
name: "Bash",
|
|
66
|
+
title: COMMAND,
|
|
67
|
+
input: { command: COMMAND },
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
const show = (n: number) =>
|
|
71
|
+
run.push({
|
|
72
|
+
type: "item",
|
|
73
|
+
item: {
|
|
74
|
+
...tool,
|
|
75
|
+
status: "running",
|
|
76
|
+
output: LINES.slice(0, Math.max(1, n)).join("\n"),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// A line every half second, then the last line stays until the hold ends.
|
|
80
|
+
const end = Date.now() + holdMs;
|
|
81
|
+
show(1);
|
|
82
|
+
for (let n = 2; n <= LINES.length && !run.stopped(); n++) {
|
|
83
|
+
if (Date.now() >= end) break;
|
|
84
|
+
await hold(run, 500);
|
|
85
|
+
show(n);
|
|
86
|
+
}
|
|
87
|
+
await hold(run, Math.max(0, end - Date.now()));
|
|
88
|
+
const stopped = run.stopped();
|
|
89
|
+
run.push({
|
|
90
|
+
type: "item",
|
|
91
|
+
item: {
|
|
92
|
+
...tool,
|
|
93
|
+
status: stopped ? "stopped" : "done",
|
|
94
|
+
output: stopped
|
|
95
|
+
? undefined
|
|
96
|
+
: `${LINES.slice(0, -1).join("\n")}\n 1 passed`,
|
|
97
|
+
endedAt: Date.now(),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runDemoScript(
|
|
103
|
+
script: DemoScript,
|
|
104
|
+
run: ScriptRun,
|
|
105
|
+
runId = String(Date.now())
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const started = Date.now();
|
|
108
|
+
const phases: DemoPhase[] = script.phase
|
|
109
|
+
? [script.phase]
|
|
110
|
+
: ["step", "wait", "retry"];
|
|
111
|
+
run.push({ type: "state", state: "running" });
|
|
112
|
+
run.push({ type: "turn_start" });
|
|
113
|
+
run.push({
|
|
114
|
+
type: "item",
|
|
115
|
+
item: {
|
|
116
|
+
id: `demo-a-${runId}`,
|
|
117
|
+
kind: "assistant",
|
|
118
|
+
text: "I'll run the checkout tests first.",
|
|
119
|
+
createdAt: started,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
run.push({ type: "prompt_tokens", tokens: 48_200 });
|
|
123
|
+
for (const phase of phases) {
|
|
124
|
+
if (run.stopped()) break;
|
|
125
|
+
if (phase === "step")
|
|
126
|
+
await runningStep(run, `demo-t-${runId}`, script.holdMs);
|
|
127
|
+
else {
|
|
128
|
+
if (phase === "retry")
|
|
129
|
+
run.push({
|
|
130
|
+
type: "model_retry",
|
|
131
|
+
attempt: 2,
|
|
132
|
+
message: "The model is overloaded",
|
|
133
|
+
});
|
|
134
|
+
await hold(run, script.holdMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
run.push({
|
|
138
|
+
type: "item",
|
|
139
|
+
item: {
|
|
140
|
+
id: `demo-end-${runId}`,
|
|
141
|
+
kind: "turn_end",
|
|
142
|
+
durationMs: Date.now() - started,
|
|
143
|
+
interrupted: run.stopped() || undefined,
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
run.push({ type: "state", state: "idle" });
|
|
148
|
+
}
|