@stigmer/sdk 2.0.0 → 3.0.0

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,122 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ createRunnerAdapter,
4
+ type RunnerWorkerHost,
5
+ } from "../runner-adapter";
6
+
7
+ function createMockHost(): RunnerWorkerHost & {
8
+ addSession: ReturnType<typeof vi.fn>;
9
+ removeSession: ReturnType<typeof vi.fn>;
10
+ addWorkflowExecution: ReturnType<typeof vi.fn>;
11
+ removeWorkflowExecution: ReturnType<typeof vi.fn>;
12
+ } {
13
+ return {
14
+ addSession: vi.fn().mockResolvedValue(undefined),
15
+ removeSession: vi.fn().mockResolvedValue(undefined),
16
+ addWorkflowExecution: vi.fn().mockResolvedValue(undefined),
17
+ removeWorkflowExecution: vi.fn().mockResolvedValue(undefined),
18
+ };
19
+ }
20
+
21
+ describe("createRunnerAdapter", () => {
22
+ it("returns an adapter with all four lifecycle methods", () => {
23
+ const adapter = createRunnerAdapter(createMockHost());
24
+
25
+ expect(adapter.onSessionOpened).toBeInstanceOf(Function);
26
+ expect(adapter.onSessionClosed).toBeInstanceOf(Function);
27
+ expect(adapter.onWorkflowExecutionCreated).toBeInstanceOf(Function);
28
+ expect(adapter.onWorkflowExecutionTerminated).toBeInstanceOf(Function);
29
+ });
30
+
31
+ it("maps onSessionOpened to host.addSession with the session id", async () => {
32
+ const host = createMockHost();
33
+ const adapter = createRunnerAdapter(host);
34
+
35
+ await adapter.onSessionOpened("ses-123");
36
+
37
+ expect(host.addSession).toHaveBeenCalledTimes(1);
38
+ expect(host.addSession).toHaveBeenCalledWith("ses-123");
39
+ // The asymmetric mapping is the footgun this factory exists to prevent:
40
+ // closing/removing must not fire on open.
41
+ expect(host.removeSession).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("maps onSessionClosed to host.removeSession with the session id", async () => {
45
+ const host = createMockHost();
46
+ const adapter = createRunnerAdapter(host);
47
+
48
+ await adapter.onSessionClosed("ses-123");
49
+
50
+ expect(host.removeSession).toHaveBeenCalledTimes(1);
51
+ expect(host.removeSession).toHaveBeenCalledWith("ses-123");
52
+ expect(host.addSession).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it("maps onWorkflowExecutionCreated to host.addWorkflowExecution with the execution id", async () => {
56
+ const host = createMockHost();
57
+ const adapter = createRunnerAdapter(host);
58
+
59
+ await adapter.onWorkflowExecutionCreated("wfexec-456");
60
+
61
+ expect(host.addWorkflowExecution).toHaveBeenCalledTimes(1);
62
+ expect(host.addWorkflowExecution).toHaveBeenCalledWith("wfexec-456");
63
+ expect(host.removeWorkflowExecution).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("maps onWorkflowExecutionTerminated to host.removeWorkflowExecution with the execution id", async () => {
67
+ const host = createMockHost();
68
+ const adapter = createRunnerAdapter(host);
69
+
70
+ await adapter.onWorkflowExecutionTerminated("wfexec-456");
71
+
72
+ expect(host.removeWorkflowExecution).toHaveBeenCalledTimes(1);
73
+ expect(host.removeWorkflowExecution).toHaveBeenCalledWith("wfexec-456");
74
+ expect(host.addWorkflowExecution).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("resolves to undefined regardless of the host's return value", async () => {
78
+ // The host's add methods return a task queue string; the adapter contract
79
+ // is Promise<void>, so the value must be swallowed.
80
+ const host = createMockHost();
81
+ host.addSession.mockResolvedValue("session:ses-123");
82
+ host.addWorkflowExecution.mockResolvedValue("wfexec:wfexec-456");
83
+ const adapter = createRunnerAdapter(host);
84
+
85
+ await expect(adapter.onSessionOpened("ses-123")).resolves.toBeUndefined();
86
+ await expect(
87
+ adapter.onWorkflowExecutionCreated("wfexec-456"),
88
+ ).resolves.toBeUndefined();
89
+ });
90
+
91
+ it("awaits the host: it does not resolve before the host settles", async () => {
92
+ let release!: () => void;
93
+ const gate = new Promise<void>((resolve) => {
94
+ release = resolve;
95
+ });
96
+ const host = createMockHost();
97
+ host.addSession.mockReturnValue(gate);
98
+ const adapter = createRunnerAdapter(host);
99
+
100
+ let settled = false;
101
+ const pending = adapter.onSessionOpened("ses-123").then(() => {
102
+ settled = true;
103
+ });
104
+
105
+ await Promise.resolve();
106
+ expect(settled).toBe(false);
107
+
108
+ release();
109
+ await pending;
110
+ expect(settled).toBe(true);
111
+ });
112
+
113
+ it("propagates a host rejection", async () => {
114
+ const host = createMockHost();
115
+ host.removeSession.mockRejectedValue(new Error("worker teardown failed"));
116
+ const adapter = createRunnerAdapter(host);
117
+
118
+ await expect(adapter.onSessionClosed("ses-123")).rejects.toThrow(
119
+ "worker teardown failed",
120
+ );
121
+ });
122
+ });
@@ -0,0 +1,144 @@
1
+ // Validates the tool-view layer against the shared cross-language contract in
2
+ // test/fixtures/tool-view/. The Go CLI runs the same fixtures, so the two
3
+ // surfaces cannot drift in classification or result interpretation.
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, resolve } from "node:path";
8
+ import { describe, it, expect } from "vitest";
9
+ import { create, type JsonObject } from "@bufbuild/protobuf";
10
+ import { ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
11
+ import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
12
+ import { ToolKind, resolveToolKindByName, normalizeToolResult } from "../tool-view";
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ // sdk/typescript/src/execution/__tests__ -> repo root is five levels up.
16
+ const fixtureDir = resolve(here, "../../../../../test/fixtures/tool-view");
17
+
18
+ function loadFixture<T>(name: string): T {
19
+ return JSON.parse(readFileSync(resolve(fixtureDir, name), "utf8")) as T;
20
+ }
21
+
22
+ describe("classification fixtures", () => {
23
+ const { cases } = loadFixture<{
24
+ cases: { name: string; mcpServerSlug: string; toolKind: string }[];
25
+ }>("classification.json");
26
+
27
+ it("loads the fixture", () => {
28
+ expect(cases.length).toBeGreaterThan(0);
29
+ });
30
+
31
+ for (const c of cases) {
32
+ const expected = ToolKind[c.toolKind.replace(/^TOOL_KIND_/, "") as keyof typeof ToolKind];
33
+ it(`classifies ${c.name || "(empty)"}${c.mcpServerSlug ? ` @${c.mcpServerSlug}` : ""}`, () => {
34
+ expect(resolveToolKindByName(c.name, c.mcpServerSlug)).toBe(expected);
35
+ });
36
+ }
37
+ });
38
+
39
+ describe("result-view fixtures", () => {
40
+ interface ResultCase {
41
+ name: string;
42
+ toolName: string;
43
+ mcpServerSlug: string;
44
+ args: Record<string, unknown>;
45
+ result: string;
46
+ error?: string;
47
+ status?: string;
48
+ expected: {
49
+ type: string;
50
+ path?: string;
51
+ exitCode?: number;
52
+ count?: number;
53
+ mcpServerSlug?: string;
54
+ linesAdded?: number;
55
+ linesRemoved?: number;
56
+ };
57
+ }
58
+
59
+ const { cases } = loadFixture<{ cases: ResultCase[] }>("result-views.json");
60
+
61
+ it("loads the fixture", () => {
62
+ expect(cases.length).toBeGreaterThan(0);
63
+ });
64
+
65
+ for (const c of cases) {
66
+ it(`normalizes ${c.name} -> ${c.expected.type}`, () => {
67
+ const status =
68
+ c.status === "TOOL_CALL_FAILED"
69
+ ? ToolCallStatus.TOOL_CALL_FAILED
70
+ : ToolCallStatus.TOOL_CALL_COMPLETED;
71
+
72
+ const toolCall = create(ToolCallSchema, {
73
+ id: c.name,
74
+ name: c.toolName,
75
+ mcpServerSlug: c.mcpServerSlug,
76
+ result: c.result,
77
+ error: c.error ?? "",
78
+ status,
79
+ args: c.args as JsonObject,
80
+ });
81
+
82
+ const view = normalizeToolResult(toolCall);
83
+ expect(view.type).toBe(c.expected.type);
84
+
85
+ // Only deterministic facts are part of the shared contract.
86
+ if (c.expected.path !== undefined && view.type === "diff") {
87
+ expect(view.path).toBe(c.expected.path);
88
+ }
89
+ if (c.expected.path !== undefined && view.type === "file") {
90
+ expect(view.path).toBe(c.expected.path);
91
+ }
92
+ if (c.expected.exitCode !== undefined && view.type === "terminal") {
93
+ expect(view.exitCode).toBe(c.expected.exitCode);
94
+ }
95
+ if (c.expected.count !== undefined && view.type === "search") {
96
+ expect(view.count).toBe(c.expected.count);
97
+ }
98
+ if (c.expected.count !== undefined && view.type === "list") {
99
+ expect(view.count).toBe(c.expected.count);
100
+ }
101
+ if (c.expected.mcpServerSlug !== undefined && view.type === "contentBlocks") {
102
+ expect(view.mcpServerSlug).toBe(c.expected.mcpServerSlug);
103
+ }
104
+ if (c.expected.linesAdded !== undefined && view.type === "diff") {
105
+ expect(view.linesAdded).toBe(c.expected.linesAdded);
106
+ }
107
+ if (c.expected.linesRemoved !== undefined && view.type === "diff") {
108
+ expect(view.linesRemoved).toBe(c.expected.linesRemoved);
109
+ }
110
+ });
111
+ }
112
+ });
113
+
114
+ describe("resolveToolKind wire field precedence", () => {
115
+ it("prefers the wire tool_kind over the name fallback", () => {
116
+ const toolCall = create(ToolCallSchema, {
117
+ name: "some_unknown_name",
118
+ toolKind: ToolKind.SHELL,
119
+ });
120
+ // resolveToolKind is exercised indirectly via normalizeToolResult routing.
121
+ const view = normalizeToolResult(
122
+ create(ToolCallSchema, {
123
+ name: "some_unknown_name",
124
+ toolKind: ToolKind.SHELL,
125
+ result: "done\n[Command succeeded]",
126
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
127
+ }),
128
+ );
129
+ expect(view.type).toBe("terminal");
130
+ expect(toolCall.toolKind).toBe(ToolKind.SHELL);
131
+ });
132
+
133
+ it("degrades unknown results to json or text", () => {
134
+ const jsonView = normalizeToolResult(
135
+ create(ToolCallSchema, { name: "mystery", result: '{"a":1}', status: ToolCallStatus.TOOL_CALL_COMPLETED }),
136
+ );
137
+ expect(jsonView.type).toBe("json");
138
+
139
+ const textView = normalizeToolResult(
140
+ create(ToolCallSchema, { name: "mystery", result: "plain", status: ToolCallStatus.TOOL_CALL_COMPLETED }),
141
+ );
142
+ expect(textView.type).toBe("text");
143
+ });
144
+ });
@@ -0,0 +1,418 @@
1
+ // Framework-agnostic tool-call view model for every Stigmer surface.
2
+ //
3
+ // Two pure functions normalize the two things clients keep re-deriving from
4
+ // harness-specific data:
5
+ // - resolveToolKind: the harness-agnostic ToolKind (wire field, with a
6
+ // name-based fallback for legacy executions persisted before tool_kind).
7
+ // - normalizeToolResult: the opaque result string -> a typed ToolResultView
8
+ // that presentation layers (React, Ink, the Go CLI's equivalent) render
9
+ // without re-parsing third-party engine formats.
10
+ //
11
+ // This module has no React or framework dependency so it can be shared by
12
+ // @stigmer/react and @stigmer/ink. The Go CLI mirrors it; the shared contract
13
+ // is test/fixtures/tool-view/. Engine result formats are version-fragile, so
14
+ // every assumption here is fixture-backed and degrades gracefully to json/text.
15
+
16
+ import { ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
17
+ import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
+ import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
19
+
20
+ export { ToolKind };
21
+
22
+ /** A single match in a search/grep result. */
23
+ export interface ToolSearchMatch {
24
+ /** File path the match was found in, when the result format provides it. */
25
+ readonly file?: string;
26
+ /** 1-based line number, when available. */
27
+ readonly line?: number;
28
+ /** The matched text. */
29
+ readonly text: string;
30
+ }
31
+
32
+ /** An MCP content block (the `content: [...]` array MCP servers return). */
33
+ export interface ToolContentBlock {
34
+ /** Block type, e.g. "text". */
35
+ readonly type: string;
36
+ /** Text payload, when the block is textual. */
37
+ readonly text?: string;
38
+ }
39
+
40
+ /**
41
+ * A typed projection of a tool call's result, discriminated by `type`.
42
+ *
43
+ * Presentation layers switch on `type` and never re-parse the raw string. The
44
+ * `json` and `text` variants are the guaranteed graceful-degradation fallbacks:
45
+ * any unrecognized or truncated payload lands there, so this is never worse than
46
+ * showing the raw result.
47
+ */
48
+ export type ToolResultView =
49
+ // File edit. Computed from args (old/new text) because the native engine's
50
+ // result carries no diff. linesAdded/linesRemoved/unifiedDiff are populated
51
+ // only when the source provides them (e.g. the Cursor SDK envelope); otherwise
52
+ // the presentation layer computes the visual diff from oldText/newText.
53
+ | {
54
+ readonly type: "diff";
55
+ readonly path: string;
56
+ readonly oldText?: string;
57
+ readonly newText?: string;
58
+ readonly linesAdded?: number;
59
+ readonly linesRemoved?: number;
60
+ readonly unifiedDiff?: string;
61
+ }
62
+ // File read or full-file write. `content` is the file body (from result for
63
+ // read, from args for write).
64
+ | {
65
+ readonly type: "file";
66
+ readonly path: string;
67
+ readonly content: string;
68
+ readonly language?: string;
69
+ readonly truncated: boolean;
70
+ }
71
+ // Shell command output. exitCode is parsed from the engine marker when present.
72
+ | {
73
+ readonly type: "terminal";
74
+ readonly stdout: string;
75
+ readonly stderr: string;
76
+ readonly exitCode?: number;
77
+ }
78
+ | { readonly type: "search"; readonly matches: readonly ToolSearchMatch[]; readonly count: number }
79
+ | { readonly type: "list"; readonly entries: readonly string[]; readonly count: number }
80
+ | {
81
+ readonly type: "contentBlocks";
82
+ readonly blocks: readonly ToolContentBlock[];
83
+ readonly mcpServerSlug: string;
84
+ }
85
+ | { readonly type: "text"; readonly text: string }
86
+ | { readonly type: "json"; readonly value: unknown }
87
+ | { readonly type: "error"; readonly message: string }
88
+ // Nothing to render (e.g. a delete confirmation, or a result not yet produced).
89
+ | { readonly type: "empty" };
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Identity: resolveToolKind
93
+ // ---------------------------------------------------------------------------
94
+
95
+ // Fallback name -> ToolKind map for legacy executions (tool_kind == UNSPECIFIED)
96
+ // and any consumer that only has a name. Mirrors the runner classifier and the
97
+ // Go CLI; kept honest by test/fixtures/tool-view/classification.json.
98
+ const NAME_TO_KIND: ReadonlyMap<string, ToolKind> = new Map([
99
+ ["read", ToolKind.FILE_READ],
100
+ ["read_file", ToolKind.FILE_READ],
101
+ ["Read", ToolKind.FILE_READ],
102
+
103
+ ["write", ToolKind.FILE_WRITE],
104
+ ["write_file", ToolKind.FILE_WRITE],
105
+ ["create_file", ToolKind.FILE_WRITE],
106
+ ["overwrite_file", ToolKind.FILE_WRITE],
107
+ ["Write", ToolKind.FILE_WRITE],
108
+
109
+ ["edit", ToolKind.FILE_EDIT],
110
+ ["edit_file", ToolKind.FILE_EDIT],
111
+ ["str_replace_editor", ToolKind.FILE_EDIT],
112
+ ["StrReplace", ToolKind.FILE_EDIT],
113
+ ["EditNotebook", ToolKind.FILE_EDIT],
114
+
115
+ ["delete", ToolKind.FILE_DELETE],
116
+ ["delete_file", ToolKind.FILE_DELETE],
117
+ ["remove_file", ToolKind.FILE_DELETE],
118
+ ["Delete", ToolKind.FILE_DELETE],
119
+
120
+ ["shell", ToolKind.SHELL],
121
+ ["bash", ToolKind.SHELL],
122
+ ["execute", ToolKind.SHELL],
123
+ ["execute_command", ToolKind.SHELL],
124
+ ["run_command", ToolKind.SHELL],
125
+ ["terminal", ToolKind.SHELL],
126
+ ["Shell", ToolKind.SHELL],
127
+
128
+ ["grep", ToolKind.SEARCH],
129
+ ["glob", ToolKind.SEARCH],
130
+ ["search", ToolKind.SEARCH],
131
+ ["ripgrep", ToolKind.SEARCH],
132
+ ["find_files", ToolKind.SEARCH],
133
+ ["Grep", ToolKind.SEARCH],
134
+ ["Glob", ToolKind.SEARCH],
135
+ ["SemanticSearch", ToolKind.SEARCH],
136
+
137
+ ["ls", ToolKind.LIST],
138
+ ["list_directory", ToolKind.LIST],
139
+
140
+ ["WebFetch", ToolKind.FETCH],
141
+ ["WebSearch", ToolKind.WEB_SEARCH],
142
+
143
+ ["think", ToolKind.THINK],
144
+
145
+ ["write_todos", ToolKind.TODO],
146
+ ["updateTodos", ToolKind.TODO],
147
+ ["TodoWrite", ToolKind.TODO],
148
+
149
+ ["task", ToolKind.SUBAGENT],
150
+ ["Task", ToolKind.SUBAGENT],
151
+ ]);
152
+
153
+ /**
154
+ * Resolves the harness-agnostic kind of a tool call.
155
+ *
156
+ * Prefers the wire `tool_kind` set by the runner; falls back to a name lookup
157
+ * for legacy executions where it is UNSPECIFIED. An unknown name with a
158
+ * non-empty mcpServerSlug is an MCP tool.
159
+ */
160
+ export function resolveToolKind(toolCall: Pick<ToolCall, "name" | "mcpServerSlug" | "toolKind">): ToolKind {
161
+ if (toolCall.toolKind !== undefined && toolCall.toolKind !== ToolKind.UNSPECIFIED) {
162
+ return toolCall.toolKind;
163
+ }
164
+ return resolveToolKindByName(toolCall.name, toolCall.mcpServerSlug);
165
+ }
166
+
167
+ /** Name-based classification used as the legacy fallback for resolveToolKind. */
168
+ export function resolveToolKindByName(name: string, mcpServerSlug?: string): ToolKind {
169
+ const builtin = NAME_TO_KIND.get(name);
170
+ if (builtin !== undefined) {
171
+ return builtin;
172
+ }
173
+ if (mcpServerSlug && mcpServerSlug.length > 0) {
174
+ return ToolKind.MCP;
175
+ }
176
+ return ToolKind.UNSPECIFIED;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Result: normalizeToolResult
181
+ // ---------------------------------------------------------------------------
182
+
183
+ type Args = Record<string, unknown> | undefined;
184
+
185
+ const PATH_FIELDS = ["path", "file_path", "file", "filename"] as const;
186
+ const OLD_TEXT_FIELDS = ["old_string", "old_text", "oldText"] as const;
187
+ const NEW_TEXT_FIELDS = ["new_string", "new_text", "newText", "replacement"] as const;
188
+ const WRITE_CONTENT_FIELDS = ["contents", "content", "file_content"] as const;
189
+
190
+ /**
191
+ * Normalizes a tool call into a typed ToolResultView for rendering.
192
+ *
193
+ * A FAILED tool with an error always yields an `error` view. Otherwise the kind
194
+ * drives interpretation, and anything unrecognized degrades to `json`/`text`.
195
+ */
196
+ export function normalizeToolResult(toolCall: ToolCall): ToolResultView {
197
+ const result = toolCall.result ?? "";
198
+ const args = toolCall.args as Args;
199
+
200
+ if (toolCall.status === ToolCallStatus.TOOL_CALL_FAILED && (toolCall.error || result)) {
201
+ return { type: "error", message: toolCall.error || result };
202
+ }
203
+
204
+ const kind = resolveToolKind(toolCall);
205
+
206
+ switch (kind) {
207
+ case ToolKind.FILE_EDIT:
208
+ return normalizeEdit(args, result);
209
+ case ToolKind.FILE_WRITE:
210
+ return normalizeWrite(args);
211
+ case ToolKind.FILE_READ:
212
+ return normalizeRead(args, result);
213
+ case ToolKind.FILE_DELETE:
214
+ return result ? { type: "text", text: result } : { type: "empty" };
215
+ case ToolKind.SHELL:
216
+ return normalizeShell(result);
217
+ case ToolKind.SEARCH:
218
+ return normalizeSearch(result);
219
+ case ToolKind.LIST:
220
+ return normalizeList(result);
221
+ case ToolKind.THINK:
222
+ return normalizeThink(args, result);
223
+ case ToolKind.MCP:
224
+ return normalizeMcp(result, toolCall.mcpServerSlug);
225
+ default:
226
+ return genericView(result);
227
+ }
228
+ }
229
+
230
+ function normalizeEdit(args: Args, result: string): ToolResultView {
231
+ const path = firstString(args, PATH_FIELDS) ?? "";
232
+ const oldText = firstString(args, OLD_TEXT_FIELDS);
233
+ const newText = firstString(args, NEW_TEXT_FIELDS);
234
+
235
+ // The Cursor SDK returns a stringified envelope with precomputed diff stats;
236
+ // prefer those when present. The native engine returns prose with no diff, so
237
+ // the presentation layer computes the visual diff from oldText/newText.
238
+ const envelope = tryParseJson(result);
239
+ const value = isRecord(envelope) && isRecord(envelope.value) ? envelope.value : undefined;
240
+ const linesAdded = value ? asNumber(value.linesAdded) : undefined;
241
+ const linesRemoved = value ? asNumber(value.linesRemoved) : undefined;
242
+ const unifiedDiff = value ? asString(value.diffString) : undefined;
243
+
244
+ return {
245
+ type: "diff",
246
+ path,
247
+ oldText,
248
+ newText,
249
+ linesAdded,
250
+ linesRemoved,
251
+ unifiedDiff,
252
+ };
253
+ }
254
+
255
+ function normalizeWrite(args: Args): ToolResultView {
256
+ const path = firstString(args, PATH_FIELDS) ?? "";
257
+ const content = firstString(args, WRITE_CONTENT_FIELDS) ?? "";
258
+ return { type: "file", path, content, language: languageFromPath(path), truncated: false };
259
+ }
260
+
261
+ function normalizeRead(args: Args, result: string): ToolResultView {
262
+ const path = firstString(args, PATH_FIELDS) ?? "";
263
+ return {
264
+ type: "file",
265
+ path,
266
+ content: result,
267
+ language: languageFromPath(path),
268
+ truncated: isTruncated(result),
269
+ };
270
+ }
271
+
272
+ // Matches the deepagents shell marker, e.g. "[Command failed with exit code 2]"
273
+ // or "[Command succeeded]". Format owned by the engine — see DD-003; covered by
274
+ // test/fixtures/tool-view/result-views.json so a format change fails one test.
275
+ const SHELL_EXIT_MARKER = /\n?\[Command (?:succeeded|failed with exit code (\d+))\]\s*$/;
276
+
277
+ function normalizeShell(result: string): ToolResultView {
278
+ // Cursor sub-agent steps can carry a structured {stdout,stderr,exitCode}.
279
+ const parsed = tryParseJson(result);
280
+ if (isRecord(parsed) && ("stdout" in parsed || "exitCode" in parsed)) {
281
+ return {
282
+ type: "terminal",
283
+ stdout: asString(parsed.stdout) ?? "",
284
+ stderr: asString(parsed.stderr) ?? "",
285
+ exitCode: asNumber(parsed.exitCode),
286
+ };
287
+ }
288
+
289
+ const marker = result.match(SHELL_EXIT_MARKER);
290
+ if (marker) {
291
+ const stdout = result.replace(SHELL_EXIT_MARKER, "");
292
+ // Group 1 is set only on failure; a matched marker without it is success (0).
293
+ const exitCode = marker[1] !== undefined ? Number(marker[1]) : 0;
294
+ return { type: "terminal", stdout, stderr: "", exitCode };
295
+ }
296
+
297
+ return { type: "terminal", stdout: result, stderr: "" };
298
+ }
299
+
300
+ // A grep-style match line, e.g. " 12: // TODO: fix".
301
+ const GREP_MATCH_LINE = /^\s*\d+[:\t]/;
302
+
303
+ function normalizeSearch(result: string): ToolResultView {
304
+ const lines = nonEmptyLines(result);
305
+ const matchLines = lines.filter((l) => GREP_MATCH_LINE.test(l));
306
+
307
+ if (matchLines.length > 0) {
308
+ const matches = matchLines.map((l) => ({ text: l.trim() }));
309
+ return { type: "search", matches, count: matches.length };
310
+ }
311
+
312
+ // Path/name results (glob, semantic search): each meaningful line is a match.
313
+ const entries = lines.filter((l) => !/^no (files|matches|results)/i.test(l));
314
+ const matches = entries.map((text) => ({ text }));
315
+ return { type: "search", matches, count: matches.length };
316
+ }
317
+
318
+ function normalizeList(result: string): ToolResultView {
319
+ const entries = nonEmptyLines(result);
320
+ return { type: "list", entries, count: entries.length };
321
+ }
322
+
323
+ function normalizeThink(args: Args, result: string): ToolResultView {
324
+ const thought = firstString(args, ["thought"]) ?? result;
325
+ return { type: "text", text: thought };
326
+ }
327
+
328
+ function normalizeMcp(result: string, mcpServerSlug: string): ToolResultView {
329
+ const parsed = tryParseJson(result);
330
+ const blocks = extractContentBlocks(parsed);
331
+ if (blocks) {
332
+ return { type: "contentBlocks", blocks, mcpServerSlug };
333
+ }
334
+ if (parsed !== undefined && (isRecord(parsed) || Array.isArray(parsed))) {
335
+ return { type: "json", value: parsed };
336
+ }
337
+ return result ? { type: "text", text: result } : { type: "empty" };
338
+ }
339
+
340
+ function genericView(result: string): ToolResultView {
341
+ if (!result) {
342
+ return { type: "empty" };
343
+ }
344
+ const parsed = tryParseJson(result);
345
+ if (parsed !== undefined && (isRecord(parsed) || Array.isArray(parsed))) {
346
+ return { type: "json", value: parsed };
347
+ }
348
+ return { type: "text", text: result };
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Helpers
353
+ // ---------------------------------------------------------------------------
354
+
355
+ function firstString(args: Args, fields: readonly string[]): string | undefined {
356
+ if (!args) return undefined;
357
+ for (const f of fields) {
358
+ const v = args[f];
359
+ if (typeof v === "string" && v.length > 0) return v;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ function nonEmptyLines(s: string): string[] {
365
+ return s.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0);
366
+ }
367
+
368
+ function tryParseJson(s: string): unknown {
369
+ const trimmed = s.trim();
370
+ if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return undefined;
371
+ try {
372
+ return JSON.parse(trimmed);
373
+ } catch {
374
+ return undefined;
375
+ }
376
+ }
377
+
378
+ function extractContentBlocks(parsed: unknown): ToolContentBlock[] | null {
379
+ const content = isRecord(parsed) ? parsed.content : Array.isArray(parsed) ? parsed : undefined;
380
+ if (!Array.isArray(content)) return null;
381
+ const blocks: ToolContentBlock[] = [];
382
+ for (const item of content) {
383
+ if (isRecord(item) && typeof item.type === "string") {
384
+ blocks.push({ type: item.type, text: asString(item.text) });
385
+ }
386
+ }
387
+ return blocks.length > 0 ? blocks : null;
388
+ }
389
+
390
+ function isRecord(v: unknown): v is Record<string, unknown> {
391
+ return typeof v === "object" && v !== null && !Array.isArray(v);
392
+ }
393
+
394
+ function asString(v: unknown): string | undefined {
395
+ return typeof v === "string" ? v : undefined;
396
+ }
397
+
398
+ function asNumber(v: unknown): number | undefined {
399
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
400
+ }
401
+
402
+ function isTruncated(result: string): boolean {
403
+ return /\[truncated: \d+ chars total\]/.test(result);
404
+ }
405
+
406
+ const EXTENSION_TO_LANGUAGE: ReadonlyMap<string, string> = new Map([
407
+ ["ts", "typescript"], ["tsx", "tsx"], ["js", "javascript"], ["jsx", "jsx"],
408
+ ["py", "python"], ["go", "go"], ["rs", "rust"], ["java", "java"],
409
+ ["rb", "ruby"], ["sh", "bash"], ["md", "markdown"], ["json", "json"],
410
+ ["yaml", "yaml"], ["yml", "yaml"], ["proto", "protobuf"], ["sql", "sql"],
411
+ ["css", "css"], ["html", "html"], ["toml", "toml"],
412
+ ]);
413
+
414
+ function languageFromPath(path: string): string | undefined {
415
+ const dot = path.lastIndexOf(".");
416
+ if (dot < 0) return undefined;
417
+ return EXTENSION_TO_LANGUAGE.get(path.slice(dot + 1).toLowerCase());
418
+ }