1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
/**
* Pure event renderer — zero I/O.
*
* Maps an AgentEvent to optional stdout/stderr strings.
* Consumers write these to process.stdout / process.stderr.
*/
import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract";
interface RenderOpts {
readonly showReasoning: boolean;
}
interface RenderOutput {
readonly stdout?: string;
readonly stderr?: string;
}
export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | undefined {
switch (e.type) {
case "text-delta":
return { stdout: e.delta };
case "reasoning-delta":
return opts.showReasoning ? { stdout: e.delta } : undefined;
case "tool-call":
return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` };
case "tool-output":
return { stdout: e.data };
case "tool-result":
return {
stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`,
};
case "usage":
return {
stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`,
};
case "error":
return { stderr: `[error] ${e.message}\n` };
case "status":
case "turn-start":
case "turn-sealed":
case "done":
return undefined;
}
}
/**
* Accumulate ALL `text-delta` events' `delta` text into one string — the full
* assistant reply assembled from a turn's event stream. Pure: input → output,
* no I/O. Returns the empty string when the stream had no text deltas.
*/
export function extractLastText(events: readonly AgentEvent[]): string {
let text = "";
for (const e of events) {
if (e.type === "text-delta") {
text += e.delta;
}
}
return text;
}
/**
* Render an epoch-ms timestamp as a short relative phrase ("just now", "5m ago",
* "3h ago", "2d ago") or, past a week, the `YYYY-MM-DD` date. `now` is injected
* (clock is an outermost edge) so the function is pure and testable.
*/
export function formatRelativeTime(epochMs: number, now: number): string {
const delta = now - epochMs;
if (delta < 60_000) return "just now";
const minutes = Math.floor(delta / 60_000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(epochMs).toISOString().slice(0, 10);
}
/**
* Format the conversation list as a table — one row per conversation:
* `shortId | title | lastActivity (relative)`. Empty input yields the empty
* string. `now` is injected for `formatRelativeTime` (pure + testable).
*/
export function formatConversationList(
conversations: readonly ConversationMeta[],
now: number,
): string {
if (conversations.length === 0) return "";
return conversations
.map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`)
.join("\n");
}
|