@scira/cli 0.1.0 → 0.1.1

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.
@@ -0,0 +1,319 @@
1
+ import { markdownToSegLines } from "./markdown.js";
2
+ import { wrapText } from "./utils.js";
3
+ /** Tools that start collapsed in the timeline (long output). */
4
+ export const DEFAULT_COLLAPSED_TOOLS = new Set([
5
+ "readUrl",
6
+ "readFile",
7
+ "readWorkspaceFile",
8
+ "readSkill",
9
+ "bash",
10
+ "runWorkspaceCommand",
11
+ "grepWorkspace",
12
+ ]);
13
+ export function feedToolItemId(feedIndex, toolCallId) {
14
+ return toolCallId ?? `feed-${feedIndex}`;
15
+ }
16
+ export function isCollapsibleToolName(name) {
17
+ return name.length > 0;
18
+ }
19
+ export function defaultCollapsedToolName(name) {
20
+ return DEFAULT_COLLAPSED_TOOLS.has(name);
21
+ }
22
+ export function isToolItemCollapsed(id, name, status, expandState) {
23
+ if (status === "running" || !isCollapsibleToolName(name))
24
+ return false;
25
+ const override = expandState.get(id);
26
+ if (override === true)
27
+ return false;
28
+ if (override === false)
29
+ return true;
30
+ return defaultCollapsedToolName(name);
31
+ }
32
+ function seg(text, style = {}) {
33
+ return { text, ...style };
34
+ }
35
+ function blank() {
36
+ return [];
37
+ }
38
+ function plainLines(text, width, style = {}) {
39
+ return wrapText(text, width).map((line) => [seg(line, style)]);
40
+ }
41
+ function tryPrettyJson(text, width, theme) {
42
+ try {
43
+ const parsed = JSON.parse(text);
44
+ const pretty = JSON.stringify(parsed, null, 2);
45
+ return plainLines(pretty, width, { color: theme.textDim });
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function dedupeSearchHits(groups) {
52
+ const seen = new Set();
53
+ const out = [];
54
+ for (const group of groups) {
55
+ for (const hit of group.results ?? []) {
56
+ const key = hit.url?.trim().toLowerCase() || `${hit.title ?? ""}:${hit.snippet ?? ""}`;
57
+ if (seen.has(key))
58
+ continue;
59
+ seen.add(key);
60
+ out.push(hit);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ function mdLinkLabel(text) {
66
+ return text.replace(/\\/gu, "\\\\").replace(/\[/gu, "\\[").replace(/\]/gu, "\\]");
67
+ }
68
+ function searchHitToMarkdown(hit) {
69
+ const title = hit.title?.trim() || hit.url || "(no title)";
70
+ const url = hit.url?.trim() ?? "";
71
+ let block = url ? `- [${mdLinkLabel(title)}](${url})` : `- ${mdLinkLabel(title)}`;
72
+ if (hit.publishedDate)
73
+ block += ` — *${hit.publishedDate}*`;
74
+ if (hit.snippet) {
75
+ const snippet = hit.snippet.replace(/\s+/gu, " ").trim();
76
+ if (snippet)
77
+ block += `\n *${snippet}*`;
78
+ }
79
+ return block;
80
+ }
81
+ function webSearchQueriesMarkdown(groups) {
82
+ const queries = groups.map((g) => g.query?.trim()).filter((q) => Boolean(q));
83
+ if (queries.length === 0)
84
+ return "";
85
+ return `## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`;
86
+ }
87
+ function webSearchSourcesMarkdown(hits) {
88
+ if (hits.length === 0)
89
+ return "";
90
+ return `## Sources (${hits.length})\n\n${hits.map(searchHitToMarkdown).join("\n\n")}`;
91
+ }
92
+ function webSearchToMarkdown(groups) {
93
+ const queries = webSearchQueriesMarkdown(groups);
94
+ const sources = webSearchSourcesMarkdown(dedupeSearchHits(groups));
95
+ if (!queries && !sources)
96
+ return "";
97
+ if (!queries)
98
+ return sources;
99
+ if (!sources)
100
+ return queries;
101
+ return `${queries}\n\n${sources}`;
102
+ }
103
+ function parseInputQueries(input) {
104
+ return input
105
+ .split(" · ")
106
+ .map((part) => part.replace(/\s+\+\d+$/u, "").trim())
107
+ .filter(Boolean);
108
+ }
109
+ function webSearchRunningMarkdown(input) {
110
+ const queries = parseInputQueries(input);
111
+ if (queries.length === 0)
112
+ return `## Queries\n\n${input}`;
113
+ return `## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`;
114
+ }
115
+ function formatWebSearch(result, width, theme) {
116
+ try {
117
+ const groups = JSON.parse(result);
118
+ if (!Array.isArray(groups))
119
+ return plainLines(result, width, { color: theme.textDim });
120
+ const md = webSearchToMarkdown(groups);
121
+ if (!md.trim())
122
+ return plainLines(result, width, { color: theme.textDim });
123
+ return markdownToSegLines(md, width, theme);
124
+ }
125
+ catch {
126
+ return plainLines(result, width, { color: theme.textDim });
127
+ }
128
+ }
129
+ function formatReadUrl(result, width, theme) {
130
+ const lines = [];
131
+ const titleMatch = result.match(/^#\s+(.+)/m);
132
+ const snapshotMatch = result.match(/\(snapshot saved to ([^)]+)\)/);
133
+ if (titleMatch?.[1]) {
134
+ lines.push([seg(titleMatch[1].trim(), { bold: true, color: theme.text })]);
135
+ }
136
+ if (snapshotMatch?.[1]) {
137
+ lines.push([
138
+ seg("saved ", { dim: true, color: theme.textDim }),
139
+ seg(snapshotMatch[1], { color: theme.accent }),
140
+ ]);
141
+ }
142
+ const bodyMarker = result.indexOf("\n\n");
143
+ const body = bodyMarker >= 0 ? result.slice(bodyMarker + 2).trim() : result.trim();
144
+ if (body) {
145
+ if (lines.length > 0)
146
+ lines.push(blank());
147
+ lines.push(...plainLines(body, width, { color: theme.textDim }));
148
+ }
149
+ return lines.length > 0 ? lines : plainLines(result, width, { color: theme.textDim });
150
+ }
151
+ function formatListSkills(result, width, theme) {
152
+ const rows = result.split("\n").map((l) => l.trim()).filter(Boolean);
153
+ if (rows.length === 0)
154
+ return [[seg("no skills", { dim: true, color: theme.textDim })]];
155
+ return rows.flatMap((row, idx) => {
156
+ const colon = row.indexOf(":");
157
+ const name = colon >= 0 ? row.slice(0, colon).trim() : row;
158
+ const desc = colon >= 0 ? row.slice(colon + 1).trim() : "";
159
+ const prefix = `${idx + 1}. `;
160
+ if (!desc) {
161
+ return [[seg(prefix, { dim: true, color: theme.textDim }), seg(name, { bold: true, color: theme.text })]];
162
+ }
163
+ const out = [[
164
+ seg(prefix, { dim: true, color: theme.textDim }),
165
+ seg(name, { bold: true, color: theme.text }),
166
+ seg(": ", { color: theme.textDim }),
167
+ ]];
168
+ for (const part of wrapText(desc, width - prefix.length - name.length - 2)) {
169
+ out.push([seg(" ", {}), seg(part, { color: theme.textDim })]);
170
+ }
171
+ return out;
172
+ });
173
+ }
174
+ function formatShellOutput(result, width, theme) {
175
+ if (!result.trim())
176
+ return [[seg("(no output)", { dim: true, color: theme.textDim })]];
177
+ return result.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
178
+ }
179
+ function formatFileContent(result, width, theme) {
180
+ const rows = result.split("\n");
181
+ const numbered = rows.map((line, i) => {
182
+ const n = String(i + 1).padStart(String(rows.length).length, " ");
183
+ return `${n} │ ${line}`;
184
+ });
185
+ return numbered.flatMap((line) => plainLines(line, width, { color: theme.textDim }));
186
+ }
187
+ function formatGrep(result, width, theme) {
188
+ const rows = result.split("\n").filter((l) => l.trim());
189
+ if (rows.length === 0)
190
+ return [[seg("no matches", { dim: true, color: theme.textDim })]];
191
+ return rows.flatMap((row) => {
192
+ const colon = row.indexOf(":");
193
+ if (colon > 0) {
194
+ return [[
195
+ seg(row.slice(0, colon + 1), { color: theme.accent }),
196
+ seg(row.slice(colon + 1), { color: theme.textDim }),
197
+ ]];
198
+ }
199
+ return plainLines(row, width, { color: theme.textDim });
200
+ });
201
+ }
202
+ function formatBody(name, result, width, theme) {
203
+ switch (name) {
204
+ case "webSearch":
205
+ return formatWebSearch(result, width, theme);
206
+ case "readUrl":
207
+ return formatReadUrl(result, width, theme);
208
+ case "listSkills":
209
+ return formatListSkills(result, width, theme);
210
+ case "bash":
211
+ case "runWorkspaceCommand":
212
+ return formatShellOutput(result, width, theme);
213
+ case "readFile":
214
+ case "readWorkspaceFile":
215
+ return formatFileContent(result, width, theme);
216
+ case "grepWorkspace":
217
+ return formatGrep(result, width, theme);
218
+ case "writeFile":
219
+ case "writeWorkspaceFile":
220
+ case "editFile":
221
+ case "editWorkspaceFile":
222
+ case "createClaim":
223
+ case "verifyClaim":
224
+ case "requestFullResearch":
225
+ case "readSkill":
226
+ return plainLines(result, width, { color: theme.text });
227
+ default: {
228
+ const json = tryPrettyJson(result, width, theme);
229
+ return json ?? plainLines(result, width, { color: theme.textDim });
230
+ }
231
+ }
232
+ }
233
+ /** One-line preview for a collapsed tool header. */
234
+ export function formatToolResultPreview(name, inputSummary, result, status) {
235
+ const input = inputSummary.replace(/\s+/gu, " ").trim();
236
+ if (status === "running")
237
+ return input ? `${input} · running…` : "running…";
238
+ if (status === "error")
239
+ return input || "failed";
240
+ if (!result?.trim())
241
+ return input || "done";
242
+ if (name === "readUrl") {
243
+ const titleMatch = result.match(/^#\s+(.+)/m);
244
+ const snapshotMatch = result.match(/\(snapshot saved to ([^)]+)\)/);
245
+ const title = titleMatch?.[1]?.trim();
246
+ const snap = snapshotMatch?.[1];
247
+ if (title && snap)
248
+ return `${title} · ${snap}`;
249
+ if (title)
250
+ return title;
251
+ return input || (result.split("\n")[0]?.slice(0, 120) ?? "page loaded");
252
+ }
253
+ if (name === "webSearch") {
254
+ try {
255
+ const groups = JSON.parse(result);
256
+ if (Array.isArray(groups)) {
257
+ const queries = groups.map((g) => g.query?.trim()).filter(Boolean);
258
+ const total = dedupeSearchHits(groups).length;
259
+ const q = queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : input;
260
+ return q ? `${q} · ${total} sources` : `${total} sources`;
261
+ }
262
+ }
263
+ catch { /* fall through */ }
264
+ }
265
+ if (name === "readFile" || name === "readWorkspaceFile") {
266
+ const lines = result.split("\n").length;
267
+ return input ? `${input} · ${lines} lines` : `${lines} lines`;
268
+ }
269
+ if (name === "bash" || name === "runWorkspaceCommand") {
270
+ const tail = result.split("\n").filter((l) => l.trim()).slice(-1)[0] ?? "";
271
+ return input ? `$ ${input}` : tail.slice(0, 100) || "done";
272
+ }
273
+ const first = result.replace(/\s+/gu, " ").trim();
274
+ return first.length > 140 ? `${first.slice(0, 137)}…` : first;
275
+ }
276
+ /** Multi-line formatted tool output for the feed panel. */
277
+ export function formatToolResultLines(name, inputSummary, result, status, contentWidth, theme, expanded = true) {
278
+ if (!expanded)
279
+ return [];
280
+ const width = Math.max(16, contentWidth);
281
+ const lines = [];
282
+ const input = inputSummary.replace(/\s+/gu, " ").trim();
283
+ const skipInput = name === "webSearch" && status === "done" && Boolean(result?.trim());
284
+ if (input && !skipInput) {
285
+ if (name === "bash" || name === "runWorkspaceCommand") {
286
+ lines.push([seg("$ ", { color: theme.accent }), seg(input, { color: theme.text })]);
287
+ }
288
+ else if (name === "webSearch") {
289
+ lines.push(...markdownToSegLines(webSearchRunningMarkdown(input), width, theme));
290
+ }
291
+ else if (name === "readUrl") {
292
+ lines.push([seg("url ", { dim: true, color: theme.textDim }), seg(input, { color: theme.accent, underline: true, url: input })]);
293
+ }
294
+ else if (name === "readFile" || name === "readWorkspaceFile" || name === "writeFile" || name === "writeWorkspaceFile" || name === "editFile" || name === "editWorkspaceFile") {
295
+ lines.push([seg("path ", { dim: true, color: theme.textDim }), seg(input, { color: theme.text })]);
296
+ }
297
+ else {
298
+ lines.push([seg(input, { color: theme.textDim })]);
299
+ }
300
+ }
301
+ if (status === "running") {
302
+ lines.push([seg("running…", { dim: true, color: theme.textDim })]);
303
+ return lines;
304
+ }
305
+ if (!result?.trim()) {
306
+ lines.push([seg(status === "error" ? "failed" : "done", { dim: true, color: theme.textDim })]);
307
+ return lines;
308
+ }
309
+ if (status === "error") {
310
+ if (lines.length > 0)
311
+ lines.push(blank());
312
+ lines.push(...plainLines(result, width, { color: theme.error }));
313
+ return lines;
314
+ }
315
+ if (lines.length > 0)
316
+ lines.push(blank());
317
+ lines.push(...formatBody(name, result, width, theme));
318
+ return lines;
319
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DARK_THEME } from "../theme.js";
3
+ import { formatToolResultLines, formatToolResultPreview, isToolItemCollapsed, defaultCollapsedToolName, } from "./tool-result.js";
4
+ function textOf(lines) {
5
+ return lines.map((row) => row.map((s) => s.text).join("")).join("\n");
6
+ }
7
+ describe("formatToolResultLines", () => {
8
+ it("shows queries then a flat sources list", () => {
9
+ const result = JSON.stringify([
10
+ {
11
+ query: "ai news",
12
+ results: [
13
+ { title: "Alpha", url: "https://a.com", snippet: "First hit snippet." },
14
+ { title: "Beta", url: "https://b.com", snippet: "Second hit snippet." },
15
+ ],
16
+ },
17
+ {
18
+ query: "ai frameworks",
19
+ results: [
20
+ { title: "Alpha", url: "https://a.com", snippet: "Duplicate should drop." },
21
+ { title: "Gamma", url: "https://c.com", snippet: "Third hit snippet." },
22
+ ],
23
+ },
24
+ ]);
25
+ const out = textOf(formatToolResultLines("webSearch", "ai news · ai frameworks", result, "done", 80, DARK_THEME));
26
+ expect(out.indexOf("Queries")).toBeLessThan(out.indexOf("Sources"));
27
+ expect(out).toContain("ai news");
28
+ expect(out).toContain("ai frameworks");
29
+ expect(out).toContain("Sources (3)");
30
+ expect(out).toContain("Alpha");
31
+ expect(out).toContain("First hit snippet.");
32
+ expect(out).toContain("Gamma");
33
+ expect(out).not.toContain("Duplicate should drop");
34
+ });
35
+ it("shows shell command and full output", () => {
36
+ const out = textOf(formatToolResultLines("bash", "ls -la", "total 0\nfile.txt", "done", 80, DARK_THEME));
37
+ expect(out).toContain("$ ls -la");
38
+ expect(out).toContain("total 0");
39
+ expect(out).toContain("file.txt");
40
+ });
41
+ it("shows readUrl title and body when expanded", () => {
42
+ const result = "# Example\n(snapshot saved to snapshots/example.md)\n\nBody paragraph here.";
43
+ const out = textOf(formatToolResultLines("readUrl", "https://example.com", result, "done", 80, DARK_THEME, true));
44
+ expect(out).toContain("Example");
45
+ expect(out).toContain("snapshots/example.md");
46
+ expect(out).toContain("Body paragraph here.");
47
+ });
48
+ it("hides readUrl body when collapsed", () => {
49
+ const result = "# Example\n(snapshot saved to snapshots/example.md)\n\nBody paragraph here.";
50
+ expect(formatToolResultLines("readUrl", "https://example.com", result, "done", 80, DARK_THEME, false)).toEqual([]);
51
+ expect(formatToolResultPreview("readUrl", "https://example.com", result, "done")).toContain("Example");
52
+ });
53
+ it("defaults readUrl collapsed and webSearch expanded", () => {
54
+ expect(defaultCollapsedToolName("readUrl")).toBe(true);
55
+ expect(defaultCollapsedToolName("webSearch")).toBe(false);
56
+ expect(isToolItemCollapsed("id", "readUrl", "done", new Map())).toBe(true);
57
+ expect(isToolItemCollapsed("id", "webSearch", "done", new Map())).toBe(false);
58
+ expect(isToolItemCollapsed("id", "readUrl", "done", new Map([["id", true]]))).toBe(false);
59
+ });
60
+ });
@@ -4,7 +4,6 @@ import { spawn } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import stringWidth from "string-width";
8
7
  import { FULL_MODE_TRIGGERS } from "../constants.js";
9
8
  export const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8")).version;
10
9
  /** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
@@ -53,16 +52,16 @@ export function prettifyModelId(id) {
53
52
  }
54
53
  /** Terminal-cell width of a string (CJK/emoji aware, strips ANSI). */
55
54
  export function displayWidth(text) {
56
- return stringWidth(text);
55
+ return Bun.stringWidth(text);
57
56
  }
58
57
  /** Longest prefix of `text` whose terminal-cell width fits in `width`; returns its char length. */
59
58
  function fitChars(text, width) {
60
- if (stringWidth(text) === text.length)
59
+ if (Bun.stringWidth(text) === text.length)
61
60
  return Math.min(text.length, width);
62
61
  let cells = 0;
63
62
  let chars = 0;
64
63
  for (const ch of text) {
65
- const w = stringWidth(ch);
64
+ const w = Bun.stringWidth(ch);
66
65
  if (cells + w > width)
67
66
  break;
68
67
  cells += w;
@@ -203,9 +202,24 @@ export function aggregateTurns(turns) {
203
202
  }
204
203
  return { total, byModel, turns };
205
204
  }
205
+ function oneLine(text, max) {
206
+ return text.replace(/\s+/gu, " ").trim().slice(0, max);
207
+ }
208
+ function toolOutputText(output) {
209
+ if (output == null)
210
+ return "";
211
+ if (typeof output === "string")
212
+ return output;
213
+ try {
214
+ return JSON.stringify(output);
215
+ }
216
+ catch {
217
+ return String(output);
218
+ }
219
+ }
206
220
  export function summarizeToolInput(name, input) {
207
221
  const obj = (input ?? {});
208
- if (name === "bash")
222
+ if (name === "bash" || name === "runWorkspaceCommand")
209
223
  return String(obj.command ?? "");
210
224
  if (name === "webSearch") {
211
225
  const queries = Array.isArray(obj.queries) ? obj.queries : [];
@@ -213,8 +227,15 @@ export function summarizeToolInput(name, input) {
213
227
  }
214
228
  if (name === "readUrl")
215
229
  return String(obj.url ?? "");
216
- if (name === "writeFile" || name === "editFile" || name === "readFile")
230
+ if (name === "writeFile" || name === "editFile" || name === "readFile" || name === "readWorkspaceFile" || name === "writeWorkspaceFile" || name === "editWorkspaceFile") {
217
231
  return String(obj.path ?? "");
232
+ }
233
+ if (name === "listWorkspaceDir" || name === "grepWorkspace")
234
+ return String(obj.path ?? obj.pattern ?? "");
235
+ if (name === "readSkill" || name === "listSkills")
236
+ return String(obj.name ?? "");
237
+ if (name === "createClaim" || name === "verifyClaim")
238
+ return String(obj.id ?? "");
218
239
  try {
219
240
  return JSON.stringify(obj).slice(0, 80);
220
241
  }
@@ -222,3 +243,64 @@ export function summarizeToolInput(name, input) {
222
243
  return "";
223
244
  }
224
245
  }
246
+ /** Short one-line summary of a completed tool's output for the feed. */
247
+ export function summarizeToolOutput(name, output) {
248
+ const text = toolOutputText(output);
249
+ if (name === "webSearch") {
250
+ try {
251
+ const parsed = JSON.parse(text);
252
+ if (Array.isArray(parsed)) {
253
+ const total = parsed.reduce((n, s) => n + (s.results?.length ?? 0), 0);
254
+ const titles = parsed
255
+ .flatMap((s) => (s.results ?? []).slice(0, 2).map((r) => r.title?.trim()).filter(Boolean))
256
+ .slice(0, 3);
257
+ const head = total > 0 ? `${total} result${total === 1 ? "" : "s"}` : "no results";
258
+ return titles.length > 0 ? `${head} · ${titles.join(" · ")}` : head;
259
+ }
260
+ }
261
+ catch { /* fall through */ }
262
+ }
263
+ if (name === "readUrl") {
264
+ const titleMatch = text.match(/^#\s+(.+)/m);
265
+ if (titleMatch?.[1])
266
+ return titleMatch[1].trim();
267
+ const snapshot = text.match(/\(snapshot saved to ([^)]+)\)/);
268
+ if (snapshot?.[1])
269
+ return `snapshot ${snapshot[1]}`;
270
+ return oneLine(text, 160);
271
+ }
272
+ if (name === "listSkills") {
273
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
274
+ if (lines.length === 0)
275
+ return "no skills";
276
+ const preview = lines.slice(0, 2).map((l) => l.split(":")[0]?.trim() || l).join(", ");
277
+ return `${lines.length} skill${lines.length === 1 ? "" : "s"} · ${preview}${lines.length > 2 ? ", …" : ""}`;
278
+ }
279
+ if (name === "readFile" || name === "readWorkspaceFile") {
280
+ const lineCount = text.split("\n").length;
281
+ const preview = oneLine(text.split("\n")[0] ?? "", 60);
282
+ return `${lineCount} line${lineCount === 1 ? "" : "s"}${preview ? ` · ${preview}` : ""}`;
283
+ }
284
+ if (name === "writeFile" || name === "writeWorkspaceFile" || name === "editFile" || name === "editWorkspaceFile") {
285
+ return oneLine(text, 120) || "ok";
286
+ }
287
+ if (name === "bash" || name === "runWorkspaceCommand") {
288
+ const lines = text.split("\n").filter((l) => l.trim());
289
+ if (lines.length === 0)
290
+ return "done";
291
+ return lines.slice(-3).map((l) => oneLine(l, 80)).join(" · ").slice(0, 200);
292
+ }
293
+ if (name === "listWorkspaceDir") {
294
+ const lines = text.split("\n").filter((l) => l.trim());
295
+ const preview = lines.slice(0, 3).map((l) => oneLine(l, 40)).join(", ");
296
+ const head = `${lines.length} entr${lines.length === 1 ? "y" : "ies"}`;
297
+ return preview ? `${head} · ${preview}${lines.length > 3 ? ", …" : ""}` : head;
298
+ }
299
+ if (name === "grepWorkspace") {
300
+ const lines = text.split("\n").filter((l) => l.trim());
301
+ if (lines.length === 0)
302
+ return "no matches";
303
+ return `${lines.length} match${lines.length === 1 ? "" : "es"} · ${oneLine(lines[0], 80)}`;
304
+ }
305
+ return oneLine(text, 200);
306
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { summarizeToolInput, summarizeToolOutput } from "./utils.js";
3
+ describe("summarizeToolInput", () => {
4
+ it("formats webSearch queries", () => {
5
+ expect(summarizeToolInput("webSearch", { queries: ["a", "b", "c"] })).toBe("a · b +1");
6
+ });
7
+ it("formats readUrl url", () => {
8
+ expect(summarizeToolInput("readUrl", { url: "https://example.com" })).toBe("https://example.com");
9
+ });
10
+ });
11
+ describe("summarizeToolOutput", () => {
12
+ it("summarizes webSearch json results", () => {
13
+ const output = JSON.stringify([
14
+ { query: "q1", results: [{ title: "Alpha" }, { title: "Beta" }] },
15
+ { query: "q2", results: [{ title: "Gamma" }] },
16
+ ]);
17
+ expect(summarizeToolOutput("webSearch", output)).toBe("3 results · Alpha · Beta · Gamma");
18
+ });
19
+ it("extracts readUrl page title", () => {
20
+ const output = "# Example Page\n(snapshot saved to snapshots/example.md)\n\nBody text";
21
+ expect(summarizeToolOutput("readUrl", output)).toBe("Example Page");
22
+ });
23
+ it("summarizes listSkills output", () => {
24
+ const output = "plan: Write a research plan\nsources: Gather sources";
25
+ expect(summarizeToolOutput("listSkills", output)).toBe("2 skills · plan, sources");
26
+ });
27
+ it("passes through predetermined string results", () => {
28
+ expect(summarizeToolOutput("requestFullResearch", "Approved. Stop now and do not call more tools."))
29
+ .toBe("Approved. Stop now and do not call more tools.");
30
+ });
31
+ });
@@ -42,6 +42,38 @@ export function abortSession(runPath) {
42
42
  export function removeSession(runPath) {
43
43
  sessions.delete(runPath);
44
44
  }
45
+ /** Merge full tool results from the session buffer into a feed snapshot before persisting. */
46
+ export function mergeFeedToolResults(feed, buffer) {
47
+ const toolById = new Map();
48
+ for (const item of buffer) {
49
+ if (item.kind === "tool" && item.toolCallId && item.result) {
50
+ toolById.set(item.toolCallId, item);
51
+ }
52
+ }
53
+ if (toolById.size === 0)
54
+ return feed;
55
+ return feed.map((item) => {
56
+ if (item.kind !== "tool" || !item.toolCallId)
57
+ return item;
58
+ const buffered = toolById.get(item.toolCallId);
59
+ if (!buffered?.result)
60
+ return item;
61
+ const keepLonger = !item.result || buffered.result.length >= item.result.length;
62
+ if (!keepLonger && item.status !== "running")
63
+ return item;
64
+ return {
65
+ ...item,
66
+ status: buffered.status,
67
+ result: keepLonger ? buffered.result : item.result,
68
+ summary: item.summary || buffered.summary,
69
+ name: item.name || buffered.name,
70
+ };
71
+ });
72
+ }
73
+ export function getSessionFeedBuffer(runPath) {
74
+ const session = sessions.get(runPath);
75
+ return session ? [...session.feedBuffer] : [];
76
+ }
45
77
  /** Route a feed item to the correct subscriber method and buffer it. */
46
78
  export function sessionPushFeed(runPath, item) {
47
79
  const session = sessions.get(runPath);
@@ -74,12 +106,14 @@ export function sessionPushFeed(runPath, item) {
74
106
  return;
75
107
  }
76
108
  if (item.kind === "tool" && (item.status === "done" || item.status === "error") && item.toolCallId) {
77
- sub.markToolDone(item.toolCallId, item.status, item.result);
78
- // Update existing tool item in buffer
109
+ const resultText = item.result;
110
+ if (!resultText)
111
+ return;
112
+ sub.markToolDone(item.toolCallId, item.status, resultText);
79
113
  for (let i = session.feedBuffer.length - 1; i >= 0; i--) {
80
114
  const b = session.feedBuffer[i];
81
115
  if (b.kind === "tool" && b.status === "running" && (b.toolCallId === item.toolCallId || !item.toolCallId)) {
82
- session.feedBuffer[i] = { ...b, status: item.status, result: item.result };
116
+ session.feedBuffer[i] = { ...b, status: item.status, result: resultText };
83
117
  break;
84
118
  }
85
119
  }
@@ -89,10 +123,13 @@ export function sessionPushFeed(runPath, item) {
89
123
  }
90
124
  // No subscriber — always buffer
91
125
  if (item.kind === "tool" && (item.status === "done" || item.status === "error") && item.toolCallId) {
126
+ const resultText = item.result;
127
+ if (!resultText)
128
+ return;
92
129
  for (let i = session.feedBuffer.length - 1; i >= 0; i--) {
93
130
  const b = session.feedBuffer[i];
94
131
  if (b.kind === "tool" && b.status === "running" && (b.toolCallId === item.toolCallId || !item.toolCallId)) {
95
- session.feedBuffer[i] = { ...b, status: item.status, result: item.result };
132
+ session.feedBuffer[i] = { ...b, status: item.status, result: resultText };
96
133
  return;
97
134
  }
98
135
  }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mergeFeedToolResults } from "./session-manager.js";
3
+ describe("mergeFeedToolResults", () => {
4
+ it("replaces truncated tool results with full buffer copies", () => {
5
+ const feed = [
6
+ {
7
+ kind: "tool",
8
+ name: "webSearch",
9
+ toolCallId: "call-1",
10
+ summary: "query a",
11
+ status: "done",
12
+ result: "truncated…",
13
+ },
14
+ ];
15
+ const buffer = [
16
+ {
17
+ kind: "tool",
18
+ name: "webSearch",
19
+ toolCallId: "call-1",
20
+ summary: "query a",
21
+ status: "done",
22
+ result: "x".repeat(5000),
23
+ },
24
+ ];
25
+ const merged = mergeFeedToolResults(feed, buffer);
26
+ expect(merged[0].kind).toBe("tool");
27
+ if (merged[0].kind === "tool") {
28
+ expect(merged[0].result).toHaveLength(5000);
29
+ }
30
+ });
31
+ });