mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(agents): cover transcript render-decision helpers + sweep dead branches
The activity-feed UI components claimed coverage "for grouping, presentation, and tool summaries" but the render-decision logic wiring those helpers into the rendered components had none. The node test harness only strips .ts (no JSX/DOM), so this extracts the pure decision logic out of the .tsx components into sibling .ts modules and tests it directly — matching the existing helper-test convention. - AgentSessionToolItem.tsx: extract isInlineImageData (the data:image/ passthrough guard), getToolDurationDisplay/formatDurationMs (duration fallback chain), and parseToolResultValue (JSON double-parse) into agentSessionUtils.ts. resolveImageSrc stays in the component (Tauri dep) but is now built on the testable isInlineImageData predicate. - ManagedAgentSessionPanel.tsx: extract scopeByChannel, deriveLatestSessionId, and resolveRawRailLayout (the raw-ACP toggle decision) into agentSessionPanelLayout.ts. - describeRawEvent (raw-view labels) gets direct coverage. New tests: agentSessionToolItemHelpers.test.mjs, rawEventRail.test.mjs, agentSessionPanelLayout.test.mjs (32 cases). Total suite 766 -> 798. Also sweeps the two parked dead branches: - agentSessionTranscriptGrouping.ts: the isSetupLifecycle guard in the activity loop can never fire (setup items are already in `consumed` and filtered out of `activity`). - agentSessionToolSummary.ts: the `base === "shell"` ternary inside the DEVELOPER_TOOL_BASES branch is unreachable (shell returns earlier); simplified to return "dev_mcp". Gates: biome check, tsc, vite build, 798 unit tests all green.
This commit is contained in:
@@ -7,7 +7,11 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { getBuzzToolInfo } from "./agentSessionToolCatalog";
|
||||
import { buildCompactToolSummary } from "./agentSessionToolSummary";
|
||||
import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils";
|
||||
import {
|
||||
formatCodeValue,
|
||||
getToolDurationDisplay,
|
||||
isInlineImageData,
|
||||
} from "./agentSessionUtils";
|
||||
|
||||
export function ToolItem({
|
||||
item,
|
||||
@@ -20,7 +24,7 @@ export function ToolItem({
|
||||
const canonicalToolName = item.buzzToolName ?? item.toolName;
|
||||
const buzzTool = getBuzzToolInfo(canonicalToolName);
|
||||
const compactSummary = buildCompactToolSummary(item);
|
||||
const duration = getToolDuration(item);
|
||||
const duration = getToolDurationDisplay(item);
|
||||
const handleToggle = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
setIsExpanded(event.currentTarget.open);
|
||||
@@ -88,6 +92,10 @@ function compactSummaryTone() {
|
||||
return "text-muted-foreground/60 group-open:text-muted-foreground";
|
||||
}
|
||||
|
||||
function resolveImageSrc(source: string): string {
|
||||
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
|
||||
}
|
||||
|
||||
function CompactToolSummaryRow({
|
||||
duration,
|
||||
label,
|
||||
@@ -188,13 +196,6 @@ function CompactMessageSummary({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImageSrc(source: string): string {
|
||||
if (source.startsWith("data:image/")) {
|
||||
return source;
|
||||
}
|
||||
return rewriteRelayUrl(source);
|
||||
}
|
||||
|
||||
function ViewImageToolPreview({
|
||||
src,
|
||||
title,
|
||||
@@ -294,48 +295,6 @@ function ImageLightbox({
|
||||
);
|
||||
}
|
||||
|
||||
function getToolDuration(item: Extract<TranscriptItem, { type: "tool" }>) {
|
||||
if (item.startedAt && item.completedAt) {
|
||||
return formatDuration(item.startedAt, item.completedAt);
|
||||
}
|
||||
|
||||
const resultRecord = asRecord(parseToolResultValue(item.result));
|
||||
const durationMs =
|
||||
getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ??
|
||||
getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]);
|
||||
return durationMs == null ? null : formatDurationMs(durationMs);
|
||||
}
|
||||
|
||||
function getToolNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number) {
|
||||
if (ms < 0) return null;
|
||||
const totalSeconds = ms / 1000;
|
||||
if (totalSeconds < 60) {
|
||||
return totalSeconds < 10
|
||||
? `${totalSeconds.toFixed(1)}s`
|
||||
: `${Math.round(totalSeconds)}s`;
|
||||
}
|
||||
let minutes = Math.floor(totalSeconds / 60);
|
||||
let seconds = Math.round(totalSeconds % 60);
|
||||
if (seconds === 60) {
|
||||
minutes += 1;
|
||||
seconds = 0;
|
||||
}
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function ToolDetailBlocks({
|
||||
args,
|
||||
description,
|
||||
@@ -416,20 +375,3 @@ function ToolCodeBlock({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseToolResultValue(result: string): unknown {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "string") return parsed;
|
||||
try {
|
||||
return JSON.parse(parsed);
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import type {
|
||||
ObserverEvent,
|
||||
TranscriptItem,
|
||||
} from "./agentSessionTypes";
|
||||
import {
|
||||
deriveLatestSessionId,
|
||||
resolveRawRailLayout,
|
||||
scopeByChannel,
|
||||
} from "./agentSessionPanelLayout";
|
||||
import { shorten } from "./agentSessionUtils";
|
||||
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
|
||||
|
||||
@@ -58,29 +63,21 @@ export function ManagedAgentSessionPanel({
|
||||
|
||||
// Filter transcript items by channelId (lightweight — items now carry channelId)
|
||||
const scopedTranscript = React.useMemo(
|
||||
() =>
|
||||
channelId
|
||||
? transcript.filter((item) => item.channelId === channelId)
|
||||
: transcript,
|
||||
() => scopeByChannel(transcript, channelId),
|
||||
[channelId, transcript],
|
||||
);
|
||||
|
||||
// Filter raw events by channelId for the RawEventRail
|
||||
const scopedEvents = React.useMemo(
|
||||
() =>
|
||||
channelId
|
||||
? events.filter((event) => event.channelId === channelId)
|
||||
: events,
|
||||
() => scopeByChannel(events, channelId),
|
||||
[channelId, events],
|
||||
);
|
||||
|
||||
// Derive latestSessionId from channel-scoped events
|
||||
const latestSessionId = React.useMemo(() => {
|
||||
for (let i = scopedEvents.length - 1; i >= 0; i--) {
|
||||
if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId;
|
||||
}
|
||||
return null;
|
||||
}, [scopedEvents]);
|
||||
const latestSessionId = React.useMemo(
|
||||
() => deriveLatestSessionId(scopedEvents),
|
||||
[scopedEvents],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -181,7 +178,9 @@ function SessionBody({
|
||||
showRaw: boolean;
|
||||
transcript: TranscriptItem[];
|
||||
}) {
|
||||
if (showRaw && rawLayout === "exclusive") {
|
||||
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
|
||||
|
||||
if (rawRail.mode === "exclusive") {
|
||||
return (
|
||||
<>
|
||||
<RawEventRail events={events} />
|
||||
@@ -205,7 +204,7 @@ function SessionBody({
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
showRaw && rawLayout === "responsive"
|
||||
rawRail.mode === "side"
|
||||
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
: "mt-0",
|
||||
)}
|
||||
@@ -219,9 +218,7 @@ function SessionBody({
|
||||
items={transcript}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{showRaw && rawLayout === "responsive" ? (
|
||||
<RawEventRail events={events} />
|
||||
) : null}
|
||||
{rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
deriveLatestSessionId,
|
||||
resolveRawRailLayout,
|
||||
scopeByChannel,
|
||||
} from "./agentSessionPanelLayout.ts";
|
||||
|
||||
// ---- scopeByChannel ----
|
||||
|
||||
const items = [
|
||||
{ id: "a", channelId: "channel-1" },
|
||||
{ id: "b", channelId: "channel-2" },
|
||||
{ id: "c", channelId: "channel-1" },
|
||||
];
|
||||
|
||||
test("scopeByChannel returns the input unchanged when channelId is null", () => {
|
||||
assert.equal(scopeByChannel(items, null), items);
|
||||
});
|
||||
|
||||
test("scopeByChannel returns the input unchanged when channelId is undefined", () => {
|
||||
assert.equal(scopeByChannel(items, undefined), items);
|
||||
});
|
||||
|
||||
test("scopeByChannel filters items down to the requested channel", () => {
|
||||
const scoped = scopeByChannel(items, "channel-1");
|
||||
assert.deepEqual(
|
||||
scoped.map((item) => item.id),
|
||||
["a", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scopeByChannel returns an empty array when no item matches", () => {
|
||||
assert.deepEqual(scopeByChannel(items, "channel-99"), []);
|
||||
});
|
||||
|
||||
// ---- deriveLatestSessionId ----
|
||||
|
||||
test("deriveLatestSessionId returns null for an empty list", () => {
|
||||
assert.equal(deriveLatestSessionId([]), null);
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId returns the last event's sessionId", () => {
|
||||
const events = [
|
||||
{ seq: 1, sessionId: "sess-1" },
|
||||
{ seq: 2, sessionId: "sess-2" },
|
||||
];
|
||||
assert.equal(deriveLatestSessionId(events), "sess-2");
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId skips trailing events without a sessionId", () => {
|
||||
const events = [
|
||||
{ seq: 1, sessionId: "sess-1" },
|
||||
{ seq: 2, sessionId: null },
|
||||
{ seq: 3, sessionId: undefined },
|
||||
];
|
||||
assert.equal(deriveLatestSessionId(events), "sess-1");
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId returns null when no event carries a sessionId", () => {
|
||||
const events = [{ seq: 1, sessionId: null }, { seq: 2 }];
|
||||
assert.equal(deriveLatestSessionId(events), null);
|
||||
});
|
||||
|
||||
// ---- resolveRawRailLayout (raw-ACP view toggle) ----
|
||||
|
||||
test("resolveRawRailLayout hides the rail when showRaw is off", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(false, "responsive"), {
|
||||
mode: "hidden",
|
||||
});
|
||||
assert.deepEqual(resolveRawRailLayout(false, "exclusive"), {
|
||||
mode: "hidden",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(true, "exclusive"), {
|
||||
mode: "exclusive",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ObserverEvent } from "./agentSessionTypes";
|
||||
|
||||
/**
|
||||
* Filter transcript items or raw observer events down to a single channel.
|
||||
* A null `channelId` means "no scoping" — the input is returned as-is.
|
||||
*/
|
||||
export function scopeByChannel<T extends { channelId?: string | null }>(
|
||||
items: readonly T[],
|
||||
channelId: string | null | undefined,
|
||||
): T[] {
|
||||
if (!channelId) return items as T[];
|
||||
return items.filter((item) => item.channelId === channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the most recent session id from a list of observer events by
|
||||
* scanning from the end. Returns null when no event carries a sessionId.
|
||||
*/
|
||||
export function deriveLatestSessionId(
|
||||
events: readonly ObserverEvent[],
|
||||
): string | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const sessionId = events[i]?.sessionId;
|
||||
if (sessionId) return sessionId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type RawRailLayout =
|
||||
| { mode: "hidden" }
|
||||
| { mode: "exclusive" }
|
||||
| { mode: "side" };
|
||||
|
||||
/**
|
||||
* Decide how the raw-ACP event rail should be rendered relative to the
|
||||
* transcript:
|
||||
* - `hidden` — raw view is off
|
||||
* - `exclusive` — raw rail replaces the transcript entirely
|
||||
* - `side` — raw rail renders alongside the transcript (responsive)
|
||||
*/
|
||||
export function resolveRawRailLayout(
|
||||
showRaw: boolean,
|
||||
rawLayout: "responsive" | "exclusive",
|
||||
): RawRailLayout {
|
||||
if (!showRaw) return { mode: "hidden" };
|
||||
if (rawLayout === "exclusive") return { mode: "exclusive" };
|
||||
return { mode: "side" };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatDurationMs,
|
||||
getToolDurationDisplay,
|
||||
isInlineImageData,
|
||||
parseToolResultValue,
|
||||
} from "./agentSessionUtils.ts";
|
||||
|
||||
// ---- isInlineImageData (dual-layer image-scheme security guard) ----
|
||||
|
||||
test("isInlineImageData accepts data:image/ URIs", () => {
|
||||
const dataUri =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC";
|
||||
assert.equal(isInlineImageData(dataUri), true);
|
||||
});
|
||||
|
||||
test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => {
|
||||
// A non-image data: URI must NOT be treated as a safe inline image —
|
||||
// it has to fall through to the relay rewrite path.
|
||||
assert.equal(
|
||||
isInlineImageData(
|
||||
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isInlineImageData("data:application/json;base64,e30="), false);
|
||||
});
|
||||
|
||||
test("isInlineImageData rejects relay-relative and absolute media URLs", () => {
|
||||
assert.equal(isInlineImageData("/media/abc123.png"), false);
|
||||
assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false);
|
||||
});
|
||||
|
||||
// ---- formatDurationMs ----
|
||||
|
||||
test("formatDurationMs returns null for negative input", () => {
|
||||
assert.equal(formatDurationMs(-1), null);
|
||||
});
|
||||
|
||||
test("formatDurationMs renders sub-10s with one decimal", () => {
|
||||
assert.equal(formatDurationMs(400), "0.4s");
|
||||
assert.equal(formatDurationMs(9900), "9.9s");
|
||||
});
|
||||
|
||||
test("formatDurationMs rounds 10s..60s to whole seconds", () => {
|
||||
assert.equal(formatDurationMs(12300), "12s");
|
||||
assert.equal(formatDurationMs(59400), "59s");
|
||||
});
|
||||
|
||||
test("formatDurationMs renders minutes and seconds", () => {
|
||||
assert.equal(formatDurationMs(90000), "1m 30s");
|
||||
assert.equal(formatDurationMs(120000), "2m");
|
||||
});
|
||||
|
||||
test("formatDurationMs carries a rounded 60s into the next minute", () => {
|
||||
// 89.7s rounds the seconds component to 60, which must carry to 1m 30s
|
||||
assert.equal(formatDurationMs(89700), "1m 30s");
|
||||
});
|
||||
|
||||
// ---- parseToolResultValue (JSON double-parse) ----
|
||||
|
||||
test("parseToolResultValue returns null for empty/whitespace", () => {
|
||||
assert.equal(parseToolResultValue(""), null);
|
||||
assert.equal(parseToolResultValue(" "), null);
|
||||
});
|
||||
|
||||
test("parseToolResultValue parses a JSON object", () => {
|
||||
assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), {
|
||||
duration_ms: 123,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseToolResultValue unwraps a double-encoded JSON string", () => {
|
||||
// The result is a JSON string that itself contains JSON.
|
||||
const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true }));
|
||||
assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true });
|
||||
});
|
||||
|
||||
test("parseToolResultValue returns the inner string when it is not JSON", () => {
|
||||
const wrapped = JSON.stringify("plain text");
|
||||
assert.equal(parseToolResultValue(wrapped), "plain text");
|
||||
});
|
||||
|
||||
test("parseToolResultValue returns null for invalid JSON", () => {
|
||||
assert.equal(parseToolResultValue("not json {"), null);
|
||||
});
|
||||
|
||||
// ---- getToolDurationDisplay (fallback chain) ----
|
||||
|
||||
const startedAt = "2026-06-14T19:00:00.000Z";
|
||||
const completedAt = "2026-06-14T19:00:02.000Z";
|
||||
|
||||
test("getToolDurationDisplay prefers start/complete timestamps", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({ startedAt, completedAt, result: "" }),
|
||||
"2.0s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay falls back to duration_ms in the result payload", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
result: JSON.stringify({ duration_ms: 3500 }),
|
||||
}),
|
||||
"3.5s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({
|
||||
result: JSON.stringify({ elapsed_ms: 65000 }),
|
||||
}),
|
||||
"1m 5s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay returns null when no duration is available", () => {
|
||||
assert.equal(getToolDurationDisplay({ result: "" }), null);
|
||||
assert.equal(
|
||||
getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ function classifyDeveloperToolName(
|
||||
if (base === "postcompact") return "post_compact_hook";
|
||||
|
||||
if (DEVELOPER_TOOL_BASES.has(base)) {
|
||||
return base === "shell" ? "shell" : "dev_mcp";
|
||||
return "dev_mcp";
|
||||
}
|
||||
|
||||
if (normalized.includes("buzz_dev_mcp")) {
|
||||
|
||||
@@ -84,9 +84,6 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
|
||||
segments.push({ kind: "item", item });
|
||||
continue;
|
||||
}
|
||||
if (isSetupLifecycle(item)) {
|
||||
continue;
|
||||
}
|
||||
segments.push({ kind: "item", item });
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,91 @@ export function asRecord(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a tool image source is an inline `data:image/` URI that should be
|
||||
* rendered as-is. This is the dual-layer image-scheme guard: only the
|
||||
* `data:image/` prefix is treated as a safe passthrough — every other scheme
|
||||
* (including other `data:` subtypes) must be routed through the relay rewriter.
|
||||
* Never widen this beyond `data:image/`.
|
||||
*/
|
||||
export function isInlineImageData(source: string): boolean {
|
||||
return source.startsWith("data:image/");
|
||||
}
|
||||
|
||||
function getToolNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format a millisecond duration; negative input yields null. */
|
||||
export function formatDurationMs(ms: number): string | null {
|
||||
if (ms < 0) return null;
|
||||
const totalSeconds = ms / 1000;
|
||||
if (totalSeconds < 60) {
|
||||
return totalSeconds < 10
|
||||
? `${totalSeconds.toFixed(1)}s`
|
||||
: `${Math.round(totalSeconds)}s`;
|
||||
}
|
||||
let minutes = Math.floor(totalSeconds / 60);
|
||||
let seconds = Math.round(totalSeconds % 60);
|
||||
if (seconds === 60) {
|
||||
minutes += 1;
|
||||
seconds = 0;
|
||||
}
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tool result string into a value. Handles the double-encoding case
|
||||
* where a JSON string itself contains JSON. Returns null on empty or invalid
|
||||
* input.
|
||||
*/
|
||||
export function parseToolResultValue(result: string): unknown {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "string") return parsed;
|
||||
try {
|
||||
return JSON.parse(parsed);
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool's display duration. Prefers the start/complete timestamps,
|
||||
* then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed
|
||||
* result payload.
|
||||
*/
|
||||
export function getToolDurationDisplay(item: {
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
result: string;
|
||||
}): string | null {
|
||||
if (item.startedAt && item.completedAt) {
|
||||
return formatDuration(item.startedAt, item.completedAt);
|
||||
}
|
||||
|
||||
const resultRecord = asRecord(parseToolResultValue(item.result));
|
||||
const durationMs =
|
||||
getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ??
|
||||
getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]);
|
||||
return durationMs == null ? null : formatDurationMs(durationMs);
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts";
|
||||
|
||||
function rawEvent(overrides = {}) {
|
||||
return {
|
||||
seq: 1,
|
||||
kind: "acp",
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("describeRawEvent surfaces the session/update sessionUpdate label", () => {
|
||||
const event = rawEvent({
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: { update: { sessionUpdate: "agent_message_chunk" } },
|
||||
},
|
||||
});
|
||||
assert.equal(describeRawEvent(event), "agent_message_chunk");
|
||||
});
|
||||
|
||||
test("describeRawEvent falls back to the method when session/update lacks an update label", () => {
|
||||
const event = rawEvent({
|
||||
payload: { method: "session/update", params: {} },
|
||||
});
|
||||
assert.equal(describeRawEvent(event), "session/update");
|
||||
});
|
||||
|
||||
test("describeRawEvent uses the method for non-session/update payloads", () => {
|
||||
const event = rawEvent({ payload: { method: "session/prompt" } });
|
||||
assert.equal(describeRawEvent(event), "session/prompt");
|
||||
});
|
||||
|
||||
test("describeRawEvent falls back to the event kind when no method is present", () => {
|
||||
const event = rawEvent({ kind: "acp_parse_error", payload: {} });
|
||||
assert.equal(describeRawEvent(event), "acp_parse_error");
|
||||
});
|
||||
Reference in New Issue
Block a user