@saadnvd1/agent-os 0.3.737 → 0.3.738
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/ChatPanel.tsx +12 -3
- 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 +21 -4
- package/lib/chat/drivers/pi-mapper.ts +17 -1
- package/lib/chat/drivers/pi.ts +13 -1
- package/lib/chat/drivers/turn-outcome.ts +25 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/group.ts +23 -3
- package/lib/chat/page.ts +3 -0
- package/lib/chat/tool-outcome.ts +45 -0
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- package/scripts/screenshots/seed.ts +9 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import {
|
|
4
5
|
memo,
|
|
5
6
|
useCallback,
|
|
@@ -99,7 +100,15 @@ interface ItemActions {
|
|
|
99
100
|
onQuote?: (text: string) => void;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function Item({
|
|
103
|
+
function Item({
|
|
104
|
+
item,
|
|
105
|
+
actions,
|
|
106
|
+
steps,
|
|
107
|
+
}: {
|
|
108
|
+
item: ChatItem;
|
|
109
|
+
actions: ItemActions;
|
|
110
|
+
steps?: StepCounts;
|
|
111
|
+
}) {
|
|
103
112
|
switch (item.kind) {
|
|
104
113
|
case "user":
|
|
105
114
|
return (
|
|
@@ -119,7 +128,7 @@ function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
|
|
|
119
128
|
case "todos":
|
|
120
129
|
return <Todos item={item} />;
|
|
121
130
|
case "turn_end":
|
|
122
|
-
return <TurnEnd item={item} />;
|
|
131
|
+
return <TurnEnd item={item} steps={steps} />;
|
|
123
132
|
case "error":
|
|
124
133
|
return <ErrorMessage item={item} />;
|
|
125
134
|
case "command_output":
|
|
@@ -209,7 +218,7 @@ const Block = memo(
|
|
|
209
218
|
</UndoneBlock>
|
|
210
219
|
) : (
|
|
211
220
|
<div data-item={b.item.id}>
|
|
212
|
-
<Item item={b.item} actions={actions} />
|
|
221
|
+
<Item item={b.item} actions={actions} steps={b.steps} />
|
|
213
222
|
</div>
|
|
214
223
|
);
|
|
215
224
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { AssistantMessage, UserMessage } from "./Messages";
|
|
4
|
+
import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
|
|
5
5
|
|
|
6
6
|
const item = {
|
|
7
7
|
id: "a1",
|
|
@@ -119,3 +119,15 @@ describe("UserMessage code blocks", () => {
|
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("TurnEnd steps", () => {
|
|
124
|
+
it("says what a stopped turn got through", () => {
|
|
125
|
+
const html = renderToStaticMarkup(
|
|
126
|
+
createElement(TurnEnd, {
|
|
127
|
+
item: { id: "e", kind: "turn_end", interrupted: true } as never,
|
|
128
|
+
steps: { total: 5, ok: 3, failed: 1, stopped: 1, running: 0 },
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import { memo } from "react";
|
|
4
5
|
import { useSnapshot } from "valtio";
|
|
5
6
|
import {
|
|
@@ -378,7 +379,23 @@ export function Todos({ item }: { item: Of<"todos"> }) {
|
|
|
378
379
|
);
|
|
379
380
|
}
|
|
380
381
|
|
|
381
|
-
|
|
382
|
+
// What a stopped or failed turn got through.
|
|
383
|
+
export function stepsLabel(s: StepCounts): string {
|
|
384
|
+
const done = s.ok + s.failed;
|
|
385
|
+
const parts = [`${done} of ${s.total} steps done`];
|
|
386
|
+
if (s.failed) parts.push(`${s.failed} failed`);
|
|
387
|
+
const unfinished = s.stopped + s.running;
|
|
388
|
+
if (unfinished) parts.push(`${unfinished} not run`);
|
|
389
|
+
return parts.join(", ");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function TurnEnd({
|
|
393
|
+
item,
|
|
394
|
+
steps,
|
|
395
|
+
}: {
|
|
396
|
+
item: Of<"turn_end">;
|
|
397
|
+
steps?: StepCounts;
|
|
398
|
+
}) {
|
|
382
399
|
const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
|
|
383
400
|
const early = !item.interrupted && item.ended;
|
|
384
401
|
const label = item.interrupted
|
|
@@ -407,7 +424,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
|
|
|
407
424
|
)}
|
|
408
425
|
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
409
426
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
410
|
-
{label}
|
|
427
|
+
{steps ? `${label} · ${stepsLabel(steps)}` : label}
|
|
411
428
|
<TurnCopy turnEndId={item.id} />
|
|
412
429
|
<TurnBookmark turnEndId={item.id} />
|
|
413
430
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
4
|
+
import { toolOutcome } from "@/lib/chat/tool-outcome";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { StatusIcon } from "./Tools";
|
|
7
|
+
|
|
8
|
+
export const MAX_CHIPS = 24;
|
|
9
|
+
|
|
10
|
+
const WORD = {
|
|
11
|
+
running: "running",
|
|
12
|
+
done: "ok",
|
|
13
|
+
error: "failed",
|
|
14
|
+
stopped: "not run",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
// One line per collapsed group: how each step ended, so the results aren't
|
|
18
|
+
// lost behind the fold.
|
|
19
|
+
export function StepOutcomes({
|
|
20
|
+
tools,
|
|
21
|
+
onOpen,
|
|
22
|
+
}: {
|
|
23
|
+
tools: ToolItem[];
|
|
24
|
+
onOpen: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
const shown = tools.slice(0, MAX_CHIPS);
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onOpen}
|
|
31
|
+
aria-label="Show steps"
|
|
32
|
+
className="flex min-h-11 w-full flex-wrap items-center gap-x-3 gap-y-1 px-2 pb-1.5 text-left md:min-h-0"
|
|
33
|
+
>
|
|
34
|
+
{shown.map((t) => {
|
|
35
|
+
const code = toolOutcome(t);
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
key={t.id}
|
|
39
|
+
title={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
40
|
+
aria-label={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
41
|
+
className="flex min-w-0 items-center gap-1"
|
|
42
|
+
>
|
|
43
|
+
<StatusIcon status={t.status} />
|
|
44
|
+
{code && (
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
"font-mono text-[11px]",
|
|
48
|
+
t.status === "error"
|
|
49
|
+
? "text-destructive"
|
|
50
|
+
: "text-muted-foreground"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{code}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
{tools.length > shown.length && (
|
|
60
|
+
<span className="text-muted-foreground/70 font-mono text-[11px]">
|
|
61
|
+
+{tools.length - shown.length} more
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { MAX_CHIPS } from "./StepOutcomes";
|
|
6
|
+
import { ToolGroup } from "./Tools";
|
|
7
|
+
|
|
8
|
+
const tool = (id: string, over: Partial<ToolItem> = {}): ToolItem => ({
|
|
9
|
+
id,
|
|
10
|
+
kind: "tool",
|
|
11
|
+
name: "Bash",
|
|
12
|
+
title: `check ${id}`,
|
|
13
|
+
input: {},
|
|
14
|
+
status: "done",
|
|
15
|
+
createdAt: 0,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const render = (tools: ToolItem[]) =>
|
|
20
|
+
renderToStaticMarkup(createElement(ToolGroup, { tools }));
|
|
21
|
+
|
|
22
|
+
describe("collapsed ToolGroup", () => {
|
|
23
|
+
it("keeps each step's outcome in view", () => {
|
|
24
|
+
const html = render([
|
|
25
|
+
tool("a", { output: "HTTP/2 200 \n" }),
|
|
26
|
+
tool("b", { status: "error", output: "Exit code 7" }),
|
|
27
|
+
tool("c", { status: "error", deferred: true, outcome: "HTTP 404" }),
|
|
28
|
+
tool("d", { status: "stopped" }),
|
|
29
|
+
]);
|
|
30
|
+
expect(html).toContain("4 steps");
|
|
31
|
+
expect(html).toContain("1 ok");
|
|
32
|
+
expect(html).toContain("2 failed");
|
|
33
|
+
expect(html).toContain("1 not run");
|
|
34
|
+
expect(html).toContain("check a: ok, HTTP 200");
|
|
35
|
+
expect(html).toContain("check b: failed, exit 7");
|
|
36
|
+
expect(html).toContain("check c: failed, HTTP 404");
|
|
37
|
+
expect(html).toContain("check d: not run");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("shows no strip while a step runs", () => {
|
|
41
|
+
expect(render([tool("a", { status: "running" })])).not.toContain(
|
|
42
|
+
"Show steps"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("caps the strip", () => {
|
|
47
|
+
const tools = Array.from({ length: MAX_CHIPS + 3 }, (_, i) =>
|
|
48
|
+
tool(`x${i}`)
|
|
49
|
+
);
|
|
50
|
+
expect(render(tools)).toContain("+3 more");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -8,6 +8,8 @@ import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
|
|
|
8
8
|
import { needsBody } from "@/lib/chat/bodyCache";
|
|
9
9
|
import { cn } from "@/lib/utils";
|
|
10
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
11
|
+
import { stepCounts, toolOutcome } from "@/lib/chat/tool-outcome";
|
|
12
|
+
import { StepOutcomes } from "./StepOutcomes";
|
|
11
13
|
import { Highlighted } from "./Code";
|
|
12
14
|
import { BackgroundButton, BackgroundState, useBackground } from "./background";
|
|
13
15
|
import { useNow } from "./useNow";
|
|
@@ -163,6 +165,18 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
163
165
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
164
166
|
{tool.title}
|
|
165
167
|
</span>
|
|
168
|
+
{toolOutcome(tool) && (
|
|
169
|
+
<span
|
|
170
|
+
className={cn(
|
|
171
|
+
"shrink-0 font-mono text-[11px]",
|
|
172
|
+
tool.status === "error"
|
|
173
|
+
? "text-destructive"
|
|
174
|
+
: "text-muted-foreground"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{toolOutcome(tool)}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
166
180
|
{tool.backgroundTaskId ? (
|
|
167
181
|
<BackgroundState tool={tool} />
|
|
168
182
|
) : (
|
|
@@ -180,8 +194,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
180
194
|
// A run of tool calls: one quiet line, expandable to every step.
|
|
181
195
|
export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
182
196
|
const running = tools.some((t) => t.status === "running");
|
|
183
|
-
const
|
|
184
|
-
const stopped = tools.filter((t) => t.status === "stopped").length;
|
|
197
|
+
const { ok, failed, stopped } = stepCounts(tools);
|
|
185
198
|
const edits = tools.filter((t) => t.diff || t.hasDiff).length;
|
|
186
199
|
const moved = tools.filter((t) => t.backgroundTaskId).length;
|
|
187
200
|
const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
|
|
@@ -211,13 +224,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
211
224
|
{!running &&
|
|
212
225
|
edits > 0 &&
|
|
213
226
|
` · ${edits} edit${edits === 1 ? "" : "s"}`}
|
|
227
|
+
{!running && (failed > 0 || stopped > 0) && ` · ${ok} ok`}
|
|
214
228
|
</span>
|
|
215
229
|
{running && <Elapsed tool={latest} />}
|
|
216
230
|
{failed > 0 && (
|
|
217
231
|
<span className="text-destructive">{failed} failed</span>
|
|
218
232
|
)}
|
|
219
|
-
{stopped > 0 &&
|
|
220
|
-
<span className="text-muted-foreground/70">stopped</span>
|
|
233
|
+
{stopped > 0 && (
|
|
234
|
+
<span className="text-muted-foreground/70">{stopped} not run</span>
|
|
221
235
|
)}
|
|
222
236
|
{!running && moved > 0 && (
|
|
223
237
|
<span className="text-muted-foreground/70">
|
|
@@ -227,6 +241,9 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
227
241
|
</button>
|
|
228
242
|
{running && <BackgroundButton tool={latest} />}
|
|
229
243
|
</div>
|
|
244
|
+
{!open && !running && (
|
|
245
|
+
<StepOutcomes tools={tools} onOpen={() => setOpen(true)} />
|
|
246
|
+
)}
|
|
230
247
|
{open && (
|
|
231
248
|
<div className="pb-1">
|
|
232
249
|
{tools.map((t) => (
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from "crypto";
|
|
|
2
2
|
import type { DriverEvent, UsageTotals } from "../events";
|
|
3
3
|
import { toolDiff, toolTitle } from "../tools";
|
|
4
4
|
import { TurnItems, type ToolStart } from "./turn-items";
|
|
5
|
-
import { logTurnEnd, turnOutcome } from "./turn-outcome";
|
|
5
|
+
import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
|
|
6
6
|
|
|
7
7
|
type P = Record<string, unknown>;
|
|
8
8
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
@@ -53,6 +53,7 @@ export class PiMapper {
|
|
|
53
53
|
private error = "";
|
|
54
54
|
// The last answer's text, its tool calls and who gave it, for the notice
|
|
55
55
|
// when a turn ends without doing anything.
|
|
56
|
+
private toolsRun = 0;
|
|
56
57
|
private last = { text: "", calls: 0, provider: "", model: "", api: "" };
|
|
57
58
|
totals: UsageTotals = {
|
|
58
59
|
costUsd: 0,
|
|
@@ -74,6 +75,8 @@ export class PiMapper {
|
|
|
74
75
|
map(e: P): void {
|
|
75
76
|
switch (e.type) {
|
|
76
77
|
case "agent_start":
|
|
78
|
+
// A nudged run continues the same turn, so its tools still count.
|
|
79
|
+
if (!this.items.inTurn) this.toolsRun = 0;
|
|
77
80
|
this.stopReason = this.error = "";
|
|
78
81
|
this.last = { text: "", calls: 0, provider: "", model: "", api: "" };
|
|
79
82
|
return this.items.startTurn();
|
|
@@ -85,6 +88,7 @@ export class PiMapper {
|
|
|
85
88
|
case "message_end":
|
|
86
89
|
return this.ended(e.message as P);
|
|
87
90
|
case "tool_execution_start":
|
|
91
|
+
this.toolsRun++;
|
|
88
92
|
return this.items.tool(
|
|
89
93
|
str(e.toolCallId),
|
|
90
94
|
piTool(str(e.toolName), e.args)
|
|
@@ -168,6 +172,18 @@ export class PiMapper {
|
|
|
168
172
|
};
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
// The run ran tools and ended without saying what they found.
|
|
176
|
+
needsSummary(): boolean {
|
|
177
|
+
return needsSummary({
|
|
178
|
+
toolsRun: this.toolsRun,
|
|
179
|
+
text: this.last.text,
|
|
180
|
+
toolCalls: this.last.calls,
|
|
181
|
+
finishReason: this.stopReason,
|
|
182
|
+
error: this.error || undefined,
|
|
183
|
+
aborted: this.aborted,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
171
187
|
// The run is over: Pi has nothing left to do for it.
|
|
172
188
|
endTurn(): void {
|
|
173
189
|
if (!this.items.inTurn) return;
|
package/lib/chat/drivers/pi.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { PiAccess, PiDialogs, writeExtension } from "./pi-approvals";
|
|
|
7
7
|
import { TEXT_TOOLS_EXTENSION } from "./pi-text-tools";
|
|
8
8
|
import { PiMapper } from "./pi-mapper";
|
|
9
9
|
import { PiRpc } from "./pi-rpc";
|
|
10
|
+
import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
|
|
10
11
|
|
|
11
12
|
type P = Record<string, unknown>;
|
|
12
13
|
|
|
@@ -17,7 +18,10 @@ export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
|
|
|
17
18
|
// A path, so a conversation started elsewhere never makes Pi ask about it.
|
|
18
19
|
if (o.resumeId) args.push("--session", o.resumeId);
|
|
19
20
|
if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
|
|
20
|
-
|
|
21
|
+
args.push(
|
|
22
|
+
"--append-system-prompt",
|
|
23
|
+
[o.systemAppend, RESULTS_BRIEF].filter(Boolean).join("\n\n")
|
|
24
|
+
);
|
|
21
25
|
return args;
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -78,6 +82,7 @@ export const piDriver: ChatDriver = {
|
|
|
78
82
|
const dialogs = new PiDialogs(emit);
|
|
79
83
|
const access = new PiAccess(options.access);
|
|
80
84
|
// Sent now: goes once the stopped run has ended.
|
|
85
|
+
let nudged = false;
|
|
81
86
|
const held: { text: string; images?: ChatImage[] }[] = [];
|
|
82
87
|
const rpc: PiRpc = new PiRpc(
|
|
83
88
|
piArgs(
|
|
@@ -132,6 +137,13 @@ export const piDriver: ChatDriver = {
|
|
|
132
137
|
if (!s.isCompacting) break;
|
|
133
138
|
await new Promise((r) => setTimeout(r, 200));
|
|
134
139
|
}
|
|
140
|
+
if (!nudged && mapper.needsSummary()) {
|
|
141
|
+
nudged = true;
|
|
142
|
+
console.info("[chat] asked the model for its results summary");
|
|
143
|
+
void prompt(SUMMARY_NUDGE);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
nudged = false;
|
|
135
147
|
mapper.endTurn();
|
|
136
148
|
void rpc
|
|
137
149
|
.request<{ contextUsage?: P }>("get_session_stats")
|
|
@@ -106,3 +106,28 @@ export function logTurnEnd(o: {
|
|
|
106
106
|
})
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
// Asked of the model once when its turn ran tools but ended without the
|
|
111
|
+
// report it set out to give.
|
|
112
|
+
export const SUMMARY_NUDGE =
|
|
113
|
+
"You ran checks or tools but haven't reported the results. Reply now with a short summary: what worked, what failed (with the exit code or HTTP status) and what you didn't check. Don't run more tools.";
|
|
114
|
+
|
|
115
|
+
// Appended to the system prompt of agents AgentOS drives itself.
|
|
116
|
+
export const RESULTS_BRIEF =
|
|
117
|
+
"After running checks, probes or discovery, end your turn with a results summary: what worked, what failed (exit code or HTTP status) and what you didn't check. Never end on a lead-in such as 'Here's what I found:' without the list.";
|
|
118
|
+
|
|
119
|
+
// Whether a turn that ran tools ended without telling what they found: an
|
|
120
|
+
// empty last reply, or one that only promises the report.
|
|
121
|
+
export function needsSummary(r: {
|
|
122
|
+
toolsRun: number;
|
|
123
|
+
text: string;
|
|
124
|
+
toolCalls: number;
|
|
125
|
+
finishReason?: string;
|
|
126
|
+
error?: string;
|
|
127
|
+
aborted?: boolean;
|
|
128
|
+
}): boolean {
|
|
129
|
+
if (r.toolsRun === 0 || r.toolCalls > 0 || r.aborted || r.error) return false;
|
|
130
|
+
if (r.finishReason === "length") return false;
|
|
131
|
+
const text = r.text.trim();
|
|
132
|
+
return !text || announcesAction(text) || text.endsWith(":");
|
|
133
|
+
}
|
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;
|
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")
|
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,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/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ChatItem } from "../../lib/chat/events";
|
|
2
|
+
|
|
3
|
+
const step = (
|
|
4
|
+
n: number,
|
|
5
|
+
start: number,
|
|
6
|
+
title: string,
|
|
7
|
+
status: "done" | "error" | "stopped",
|
|
8
|
+
output?: string
|
|
9
|
+
): ChatItem => ({
|
|
10
|
+
id: `tool-${n}`,
|
|
11
|
+
createdAt: start + n * 4000,
|
|
12
|
+
kind: "tool",
|
|
13
|
+
name: "Bash",
|
|
14
|
+
title,
|
|
15
|
+
input: { command: title },
|
|
16
|
+
status,
|
|
17
|
+
output,
|
|
18
|
+
endedAt: start + n * 4000 + 1500,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// A reachability check cut short: some steps answered, one failed, two
|
|
22
|
+
// never ran.
|
|
23
|
+
export function verifyConversation(start: number): ChatItem[] {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
id: "user-1",
|
|
27
|
+
createdAt: start,
|
|
28
|
+
kind: "user",
|
|
29
|
+
text: "Which services can you reach from here before you scope the sync job?",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "assistant-1",
|
|
33
|
+
createdAt: start + 2000,
|
|
34
|
+
kind: "assistant",
|
|
35
|
+
text: "Here's what I can verify is reachable from this box, before I scope:",
|
|
36
|
+
},
|
|
37
|
+
step(
|
|
38
|
+
1,
|
|
39
|
+
start,
|
|
40
|
+
"curl -sI https://api.example.test/health",
|
|
41
|
+
"done",
|
|
42
|
+
"HTTP/2 200 \ncontent-type: application/json"
|
|
43
|
+
),
|
|
44
|
+
step(
|
|
45
|
+
2,
|
|
46
|
+
start,
|
|
47
|
+
"curl -sI https://billing.example.test/v1",
|
|
48
|
+
"done",
|
|
49
|
+
"HTTP/2 401 \nwww-authenticate: Bearer"
|
|
50
|
+
),
|
|
51
|
+
step(
|
|
52
|
+
3,
|
|
53
|
+
start,
|
|
54
|
+
"pg_isready -h db.internal",
|
|
55
|
+
"error",
|
|
56
|
+
"db.internal:5432 - no response\nExit code 2"
|
|
57
|
+
),
|
|
58
|
+
step(4, start, "curl -sI https://search.example.test", "stopped"),
|
|
59
|
+
step(5, start, "redis-cli -h cache.internal ping", "stopped"),
|
|
60
|
+
{
|
|
61
|
+
id: "turn-1",
|
|
62
|
+
createdAt: start + 40000,
|
|
63
|
+
kind: "turn_end",
|
|
64
|
+
durationMs: 40000,
|
|
65
|
+
interrupted: true,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* npx tsx scripts/screenshots/seed.ts
|
|
7
7
|
*/
|
|
8
|
+
import { verifyConversation } from "./chat-verify";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import path from "path";
|
|
10
11
|
import { randomUUID } from "crypto";
|
|
@@ -148,6 +149,14 @@ const SESSIONS: SessionSeed[] = [
|
|
|
148
149
|
minutesAgo: 60 * 5,
|
|
149
150
|
chat: docsConversation,
|
|
150
151
|
},
|
|
152
|
+
{
|
|
153
|
+
key: "verify",
|
|
154
|
+
name: "sync-reachability",
|
|
155
|
+
project: "ml-pipeline",
|
|
156
|
+
view: "chat",
|
|
157
|
+
minutesAgo: 60 * 8,
|
|
158
|
+
chat: verifyConversation,
|
|
159
|
+
},
|
|
151
160
|
{
|
|
152
161
|
key: "backfill",
|
|
153
162
|
name: "feature-backfill",
|