@tjamescouch/gro 1.3.2

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.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/README.md +218 -0
  3. package/_base.md +44 -0
  4. package/gro +198 -0
  5. package/owl/behaviors/agentic-turn.md +43 -0
  6. package/owl/components/cli.md +37 -0
  7. package/owl/components/drivers.md +29 -0
  8. package/owl/components/mcp.md +33 -0
  9. package/owl/components/memory.md +35 -0
  10. package/owl/components/session.md +35 -0
  11. package/owl/constraints.md +32 -0
  12. package/owl/product.md +28 -0
  13. package/owl/proposals/cooperative-scheduler.md +106 -0
  14. package/package.json +22 -0
  15. package/providers/claude.sh +50 -0
  16. package/providers/gemini.sh +36 -0
  17. package/providers/openai.py +85 -0
  18. package/src/drivers/anthropic.ts +215 -0
  19. package/src/drivers/index.ts +5 -0
  20. package/src/drivers/streaming-openai.ts +245 -0
  21. package/src/drivers/types.ts +33 -0
  22. package/src/errors.ts +97 -0
  23. package/src/logger.ts +28 -0
  24. package/src/main.ts +827 -0
  25. package/src/mcp/client.ts +147 -0
  26. package/src/mcp/index.ts +2 -0
  27. package/src/memory/advanced-memory.ts +263 -0
  28. package/src/memory/agent-memory.ts +61 -0
  29. package/src/memory/agenthnsw.ts +122 -0
  30. package/src/memory/index.ts +6 -0
  31. package/src/memory/simple-memory.ts +41 -0
  32. package/src/memory/vector-index.ts +30 -0
  33. package/src/session.ts +150 -0
  34. package/src/tools/agentpatch.ts +89 -0
  35. package/src/tools/bash.ts +61 -0
  36. package/src/utils/rate-limiter.ts +60 -0
  37. package/src/utils/retry.ts +32 -0
  38. package/src/utils/timed-fetch.ts +29 -0
  39. package/tests/errors.test.ts +246 -0
  40. package/tests/memory.test.ts +186 -0
  41. package/tests/rate-limiter.test.ts +76 -0
  42. package/tests/retry.test.ts +138 -0
  43. package/tests/timed-fetch.test.ts +104 -0
  44. package/tsconfig.json +13 -0
package/src/session.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { ChatMessage } from "./drivers/types.js";
5
+ import { Logger } from "./logger.js";
6
+ import { groError, asError, errorLogFields } from "./errors.js";
7
+
8
+ /**
9
+ * Session persistence for gro.
10
+ *
11
+ * Layout:
12
+ * .gro/
13
+ * context/
14
+ * <session-id>/
15
+ * messages.json — full message history
16
+ * meta.json — session metadata (model, provider, timestamps)
17
+ */
18
+
19
+ export interface SessionMeta {
20
+ id: string;
21
+ provider: string;
22
+ model: string;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ }
26
+
27
+ const GRO_DIR = ".gro";
28
+ const CONTEXT_DIR = "context";
29
+
30
+ function groDir(): string {
31
+ return join(process.cwd(), GRO_DIR);
32
+ }
33
+
34
+ function contextDir(): string {
35
+ return join(groDir(), CONTEXT_DIR);
36
+ }
37
+
38
+ function sessionDir(id: string): string {
39
+ return join(contextDir(), id);
40
+ }
41
+
42
+ /**
43
+ * Ensure the .gro/context directory exists.
44
+ */
45
+ export function ensureGroDir(): void {
46
+ const dir = contextDir();
47
+ if (!existsSync(dir)) {
48
+ mkdirSync(dir, { recursive: true });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Generate a new session ID (short UUID prefix for readability).
54
+ */
55
+ export function newSessionId(): string {
56
+ return randomUUID().split("-")[0];
57
+ }
58
+
59
+ /**
60
+ * Save a session to disk.
61
+ */
62
+ export function saveSession(
63
+ id: string,
64
+ messages: ChatMessage[],
65
+ meta: Omit<SessionMeta, "updatedAt">,
66
+ ): void {
67
+ const dir = sessionDir(id);
68
+ if (!existsSync(dir)) {
69
+ mkdirSync(dir, { recursive: true });
70
+ }
71
+
72
+ const fullMeta: SessionMeta = {
73
+ ...meta,
74
+ updatedAt: new Date().toISOString(),
75
+ };
76
+
77
+ writeFileSync(join(dir, "messages.json"), JSON.stringify(messages, null, 2));
78
+ writeFileSync(join(dir, "meta.json"), JSON.stringify(fullMeta, null, 2));
79
+ }
80
+
81
+ /**
82
+ * Load a session from disk. Returns null if not found.
83
+ */
84
+ export function loadSession(id: string): { messages: ChatMessage[]; meta: SessionMeta } | null {
85
+ const dir = sessionDir(id);
86
+ const msgPath = join(dir, "messages.json");
87
+ const metaPath = join(dir, "meta.json");
88
+
89
+ if (!existsSync(msgPath) || !existsSync(metaPath)) {
90
+ return null;
91
+ }
92
+
93
+ try {
94
+ const messages = JSON.parse(readFileSync(msgPath, "utf-8"));
95
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
96
+ return { messages, meta };
97
+ } catch (e: unknown) {
98
+ const ge = groError("session_error", `Failed to load session ${id}: ${asError(e).message}`, { cause: e });
99
+ Logger.warn(ge.message, errorLogFields(ge));
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Find the most recent session (for --continue).
106
+ */
107
+ export function findLatestSession(): string | null {
108
+ const dir = contextDir();
109
+ if (!existsSync(dir)) return null;
110
+
111
+ let latest: { id: string; mtime: number } | null = null;
112
+
113
+ for (const entry of readdirSync(dir)) {
114
+ const metaPath = join(dir, entry, "meta.json");
115
+ if (existsSync(metaPath)) {
116
+ const stat = statSync(metaPath);
117
+ if (!latest || stat.mtimeMs > latest.mtime) {
118
+ latest = { id: entry, mtime: stat.mtimeMs };
119
+ }
120
+ }
121
+ }
122
+
123
+ return latest?.id ?? null;
124
+ }
125
+
126
+ /**
127
+ * List all sessions, sorted by most recent first.
128
+ */
129
+ export function listSessions(): SessionMeta[] {
130
+ const dir = contextDir();
131
+ if (!existsSync(dir)) return [];
132
+
133
+ const sessions: (SessionMeta & { mtime: number })[] = [];
134
+
135
+ for (const entry of readdirSync(dir)) {
136
+ const metaPath = join(dir, entry, "meta.json");
137
+ if (existsSync(metaPath)) {
138
+ try {
139
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
140
+ const stat = statSync(metaPath);
141
+ sessions.push({ ...meta, mtime: stat.mtimeMs });
142
+ } catch {
143
+ // skip corrupt sessions
144
+ }
145
+ }
146
+ }
147
+
148
+ sessions.sort((a, b) => b.mtime - a.mtime);
149
+ return sessions.map(({ mtime: _, ...rest }) => rest);
150
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * agentpatch tool integration for gro.
3
+ *
4
+ * Exposes a first-class file editor: `apply_patch`.
5
+ *
6
+ * This wraps the agentpatch patch grammar and applies patches via the
7
+ * `agentpatch/bin/apply_patch` script.
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { Logger } from "../logger.js";
13
+
14
+ const DEFAULT_TIMEOUT = 120_000;
15
+ const MAX_OUTPUT = 30_000;
16
+
17
+ export function agentpatchToolDefinition(): any {
18
+ return {
19
+ type: "function",
20
+ function: {
21
+ name: "apply_patch",
22
+ description: "Apply a unified agentpatch-style patch to the working tree (safe, idempotent).",
23
+ parameters: {
24
+ type: "object",
25
+ properties: {
26
+ patch: { type: "string", description: "The patch text (agentpatch grammar)." },
27
+ dry_run: { type: "boolean", description: "If true, validate/preview without writing." },
28
+ verbose: { type: "boolean", description: "If true, emit debug logs from applier." },
29
+ allow_delete: { type: "boolean", description: "Allow *** Delete File ops." },
30
+ allow_rename: { type: "boolean", description: "Allow *** Rename File ops." },
31
+ timeout: { type: "number", description: "Timeout in ms (default 120000)." },
32
+ },
33
+ required: ["patch"],
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ function truncate(s: string): string {
40
+ if (s.length <= MAX_OUTPUT) return s;
41
+ const half = Math.floor(MAX_OUTPUT / 2);
42
+ return s.slice(0, half) + `\n\n... (truncated ${s.length - MAX_OUTPUT} chars) ...\n\n` + s.slice(-half);
43
+ }
44
+
45
+ export function executeAgentpatch(args: Record<string, any>): string {
46
+ const patch = (args.patch as string) || "";
47
+ if (!patch.trim()) return "Error: empty patch";
48
+
49
+ const timeout = (args.timeout as number) || DEFAULT_TIMEOUT;
50
+ const dryRun = args.dry_run === true;
51
+ const verbose = args.verbose === true;
52
+ const allowDelete = args.allow_delete === true;
53
+ const allowRename = args.allow_rename === true;
54
+
55
+ // Expected layout in this monorepo-ish runner: /home/agent/agentpatch
56
+ // If not present, instruct user to clone it.
57
+ const agentpatchPath = process.env.AGENTPATCH_PATH || join(process.env.HOME || "", "agentpatch");
58
+ const bin = join(agentpatchPath, "bin", "apply_patch");
59
+ if (!existsSync(bin)) {
60
+ return `Error: agentpatch not found at ${bin}. Set AGENTPATCH_PATH or clone agentpatch to ~/agentpatch.`;
61
+ }
62
+
63
+ const cmd = [bin];
64
+ if (dryRun) cmd.push("--dry-run");
65
+ if (verbose) cmd.push("--verbose");
66
+ if (allowDelete) cmd.push("--allow-delete");
67
+ if (allowRename) cmd.push("--allow-rename");
68
+
69
+ Logger.debug(`apply_patch: ${cmd.join(" ")}`);
70
+
71
+ try {
72
+ const out = execSync(cmd.join(" "), {
73
+ shell: "/bin/bash",
74
+ input: patch,
75
+ encoding: "utf-8",
76
+ timeout,
77
+ maxBuffer: 10 * 1024 * 1024,
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ });
80
+ return truncate(out || "ok");
81
+ } catch (e: any) {
82
+ let result = "";
83
+ if (e.stdout) result += e.stdout;
84
+ if (e.stderr) result += (result ? "\n" : "") + e.stderr;
85
+ if (!result) result = e.message || "Command failed";
86
+ if (e.status != null) result += `\n[exit code: ${e.status}]`;
87
+ return truncate(result);
88
+ }
89
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Built-in bash tool for gro — executes shell commands and returns output.
3
+ * Gated behind --bash flag. Not enabled by default.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { Logger } from "../logger.js";
7
+
8
+ const MAX_OUTPUT = 30_000;
9
+ const DEFAULT_TIMEOUT = 120_000;
10
+
11
+ export function bashToolDefinition(): any {
12
+ return {
13
+ type: "function",
14
+ function: {
15
+ name: "bash",
16
+ description: "Execute a bash command and return its output (stdout + stderr).",
17
+ parameters: {
18
+ type: "object",
19
+ properties: {
20
+ command: { type: "string", description: "The bash command to execute" },
21
+ timeout: { type: "number", description: "Timeout in milliseconds (default: 120000)" },
22
+ },
23
+ required: ["command"],
24
+ },
25
+ },
26
+ };
27
+ }
28
+
29
+ export function executeBash(args: Record<string, any>): string {
30
+ const command = args.command as string;
31
+ if (!command) return "Error: no command provided";
32
+
33
+ const timeout = (args.timeout as number) || DEFAULT_TIMEOUT;
34
+
35
+ Logger.debug(`bash: ${command}`);
36
+
37
+ try {
38
+ const output = execSync(command, {
39
+ shell: "/bin/bash",
40
+ encoding: "utf-8",
41
+ timeout,
42
+ maxBuffer: 10 * 1024 * 1024,
43
+ stdio: ["pipe", "pipe", "pipe"],
44
+ });
45
+ return truncate(output);
46
+ } catch (e: any) {
47
+ // execSync throws on non-zero exit — capture stdout + stderr
48
+ let result = "";
49
+ if (e.stdout) result += e.stdout;
50
+ if (e.stderr) result += (result ? "\n" : "") + e.stderr;
51
+ if (!result) result = e.message || "Command failed";
52
+ if (e.status != null) result += `\n[exit code: ${e.status}]`;
53
+ return truncate(result);
54
+ }
55
+ }
56
+
57
+ function truncate(s: string): string {
58
+ if (s.length <= MAX_OUTPUT) return s;
59
+ const half = Math.floor(MAX_OUTPUT / 2);
60
+ return s.slice(0, half) + `\n\n... (truncated ${s.length - MAX_OUTPUT} chars) ...\n\n` + s.slice(-half);
61
+ }
@@ -0,0 +1,60 @@
1
+ type LimiterState = {
2
+ nextAvailableMs: number;
3
+ tail: Promise<void>;
4
+ };
5
+
6
+ function sleep(ms: number): Promise<void> {
7
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
8
+ }
9
+
10
+ class RateLimiter {
11
+ private states = new Map<string, LimiterState>();
12
+ private readonly now: () => number;
13
+
14
+ constructor(now?: () => number) {
15
+ this.now =
16
+ now ??
17
+ (() =>
18
+ typeof performance !== "undefined" && typeof performance.now === "function"
19
+ ? performance.now()
20
+ : Date.now());
21
+ }
22
+
23
+ async limit(name: string, throughputPerSecond: number): Promise<void> {
24
+ if (!Number.isFinite(throughputPerSecond) || throughputPerSecond <= 0) {
25
+ throw new RangeError(
26
+ `throughputPerSecond must be a positive finite number; got ${throughputPerSecond}`
27
+ );
28
+ }
29
+ const key = name || "default";
30
+ const state = this.getState(key);
31
+
32
+ const waitPromise = state.tail.then(async () => {
33
+ const intervalMs = 1000 / throughputPerSecond;
34
+ const now = this.now();
35
+ const scheduledAt = Math.max(now, state.nextAvailableMs);
36
+ state.nextAvailableMs = scheduledAt + intervalMs;
37
+ const delay = scheduledAt - now;
38
+ if (delay > 0) await sleep(delay);
39
+ });
40
+
41
+ state.tail = waitPromise.catch(() => {});
42
+ return waitPromise;
43
+ }
44
+
45
+ reset(name?: string): void {
46
+ if (name) this.states.delete(name);
47
+ else this.states.clear();
48
+ }
49
+
50
+ private getState(name: string): LimiterState {
51
+ let s = this.states.get(name);
52
+ if (!s) {
53
+ s = { nextAvailableMs: this.now(), tail: Promise.resolve() };
54
+ this.states.set(name, s);
55
+ }
56
+ return s;
57
+ }
58
+ }
59
+
60
+ export const rateLimiter = new RateLimiter();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Retry utilities for API drivers.
3
+ *
4
+ * Shared between Anthropic and OpenAI drivers to avoid duplication.
5
+ */
6
+
7
+ export const MAX_RETRIES = 3;
8
+ export const RETRY_BASE_MS = 1000;
9
+
10
+ /**
11
+ * Check if an HTTP status code is retryable.
12
+ * 429 = rate limited, 502/503 = upstream error, 529 = overloaded.
13
+ */
14
+ export function isRetryable(status: number): boolean {
15
+ return status === 429 || status === 502 || status === 503 || status === 529;
16
+ }
17
+
18
+ /**
19
+ * Calculate retry delay with exponential backoff + jitter.
20
+ * attempt 0 → base 1s + 0-0.5s jitter
21
+ * attempt 1 → base 2s + 0-1s jitter
22
+ * attempt 2 → base 4s + 0-2s jitter
23
+ */
24
+ export function retryDelay(attempt: number): number {
25
+ const base = RETRY_BASE_MS * Math.pow(2, attempt);
26
+ const jitter = Math.random() * base * 0.5;
27
+ return base + jitter;
28
+ }
29
+
30
+ export function sleep(ms: number): Promise<void> {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
@@ -0,0 +1,29 @@
1
+ import { asError } from "../errors.js";
2
+
3
+ /** Wrap fetch with timeout and location context for debugging. */
4
+ export async function timedFetch(
5
+ url: string,
6
+ init: RequestInit & { timeoutMs?: number; where?: string } = {}
7
+ ) {
8
+ const { timeoutMs, where, ...rest } = init;
9
+ let controller: AbortController | null = null;
10
+ let timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ try {
13
+ if (timeoutMs && timeoutMs > 0) {
14
+ controller = new AbortController();
15
+ (rest as any).signal = controller.signal;
16
+ timer = setTimeout(() => controller!.abort(), timeoutMs);
17
+ }
18
+ return await fetch(url, rest);
19
+ } catch (e: unknown) {
20
+ const wrapped = asError(e);
21
+ const err = new Error(
22
+ `[fetch timeout] ${where ?? ""} ${url} -> ${wrapped.name}: ${wrapped.message}`
23
+ );
24
+ (err as any).cause = e;
25
+ throw err;
26
+ } finally {
27
+ if (timer) clearTimeout(timer);
28
+ }
29
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Tests for structured error types (GroError).
3
+ *
4
+ * Covers: groError, asError, isGroError, errorLogFields
5
+ */
6
+
7
+ import { test, describe } from "node:test";
8
+ import assert from "node:assert";
9
+ import { groError, asError, isGroError, errorLogFields } from "../src/errors.js";
10
+ import type { GroError } from "../src/errors.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // groError factory
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("groError", () => {
17
+ test("creates an Error with kind and retryable fields", () => {
18
+ const e = groError("provider_error", "API failed");
19
+ assert.ok(e instanceof Error);
20
+ assert.strictEqual(e.kind, "provider_error");
21
+ assert.strictEqual(e.message, "API failed");
22
+ assert.strictEqual(e.retryable, false); // default
23
+ assert.ok(e.stack, "should have a stack trace");
24
+ });
25
+
26
+ test("retryable defaults to false", () => {
27
+ const e = groError("tool_error", "boom");
28
+ assert.strictEqual(e.retryable, false);
29
+ });
30
+
31
+ test("retryable can be set to true", () => {
32
+ const e = groError("provider_error", "429", { retryable: true });
33
+ assert.strictEqual(e.retryable, true);
34
+ });
35
+
36
+ test("optional fields are set when provided", () => {
37
+ const e = groError("provider_error", "fail", {
38
+ provider: "anthropic",
39
+ model: "claude-sonnet-4-20250514",
40
+ request_id: "req_123",
41
+ latency_ms: 450,
42
+ cause: new Error("underlying"),
43
+ });
44
+ assert.strictEqual(e.provider, "anthropic");
45
+ assert.strictEqual(e.model, "claude-sonnet-4-20250514");
46
+ assert.strictEqual(e.request_id, "req_123");
47
+ assert.strictEqual(e.latency_ms, 450);
48
+ assert.ok(e.cause instanceof Error);
49
+ });
50
+
51
+ test("optional fields are omitted when not provided", () => {
52
+ const e = groError("config_error", "bad config");
53
+ assert.strictEqual(e.provider, undefined);
54
+ assert.strictEqual(e.model, undefined);
55
+ assert.strictEqual(e.request_id, undefined);
56
+ assert.strictEqual(e.latency_ms, undefined);
57
+ assert.strictEqual(e.cause, undefined);
58
+ });
59
+
60
+ test("all GroErrorKind values are accepted", () => {
61
+ const kinds = [
62
+ "provider_error",
63
+ "tool_error",
64
+ "config_error",
65
+ "mcp_error",
66
+ "timeout_error",
67
+ "session_error",
68
+ ] as const;
69
+ for (const kind of kinds) {
70
+ const e = groError(kind, `test ${kind}`);
71
+ assert.strictEqual(e.kind, kind);
72
+ }
73
+ });
74
+
75
+ test("latency_ms of 0 is preserved (not treated as falsy)", () => {
76
+ const e = groError("provider_error", "fast fail", { latency_ms: 0 });
77
+ assert.strictEqual(e.latency_ms, 0);
78
+ });
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // asError
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("asError", () => {
86
+ test("returns Error instances unchanged", () => {
87
+ const original = new Error("original");
88
+ const result = asError(original);
89
+ assert.strictEqual(result, original);
90
+ });
91
+
92
+ test("wraps strings into Error", () => {
93
+ const result = asError("something broke");
94
+ assert.ok(result instanceof Error);
95
+ assert.strictEqual(result.message, "something broke");
96
+ });
97
+
98
+ test("truncates long strings to 1024 chars", () => {
99
+ const long = "x".repeat(2000);
100
+ const result = asError(long);
101
+ assert.strictEqual(result.message.length, 1024);
102
+ });
103
+
104
+ test("handles null", () => {
105
+ const result = asError(null);
106
+ assert.ok(result instanceof Error);
107
+ assert.strictEqual(result.message, "Unknown error");
108
+ });
109
+
110
+ test("handles undefined", () => {
111
+ const result = asError(undefined);
112
+ assert.ok(result instanceof Error);
113
+ assert.strictEqual(result.message, "Unknown error");
114
+ });
115
+
116
+ test("handles objects via String()", () => {
117
+ const result = asError({ code: 42 });
118
+ assert.ok(result instanceof Error);
119
+ assert.ok(result.message.includes("[object Object]"));
120
+ });
121
+
122
+ test("handles numbers", () => {
123
+ const result = asError(42);
124
+ assert.ok(result instanceof Error);
125
+ assert.strictEqual(result.message, "42");
126
+ });
127
+
128
+ test("truncates long stringified values with ellipsis", () => {
129
+ // Use a value whose String() representation exceeds 1024 chars
130
+ const longStr = "z".repeat(2000);
131
+ const result = asError(longStr);
132
+ // String input goes through the string branch (slice to 1024, no ellipsis)
133
+ assert.strictEqual(result.message.length, 1024);
134
+
135
+ // For non-string values, String() > 1024 gets ellipsis
136
+ const longToString = { toString: () => "w".repeat(2000) };
137
+ const result2 = asError(longToString);
138
+ assert.ok(result2.message.endsWith("..."));
139
+ assert.strictEqual(result2.message.length, 1027); // 1024 + "..."
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // isGroError
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("isGroError", () => {
148
+ test("returns true for groError instances", () => {
149
+ const e = groError("provider_error", "test");
150
+ assert.strictEqual(isGroError(e), true);
151
+ });
152
+
153
+ test("returns false for plain Error", () => {
154
+ assert.strictEqual(isGroError(new Error("nope")), false);
155
+ });
156
+
157
+ test("returns false for non-Error objects", () => {
158
+ assert.strictEqual(isGroError({ kind: "provider_error", retryable: true }), false);
159
+ });
160
+
161
+ test("returns false for null", () => {
162
+ assert.strictEqual(isGroError(null), false);
163
+ });
164
+
165
+ test("returns false for undefined", () => {
166
+ assert.strictEqual(isGroError(undefined), false);
167
+ });
168
+
169
+ test("returns false for strings", () => {
170
+ assert.strictEqual(isGroError("error"), false);
171
+ });
172
+
173
+ test("returns true for Error manually augmented with kind and retryable", () => {
174
+ const e = new Error("manual") as any;
175
+ e.kind = "tool_error";
176
+ e.retryable = false;
177
+ assert.strictEqual(isGroError(e), true);
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // errorLogFields
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("errorLogFields", () => {
186
+ test("returns basic fields for minimal GroError", () => {
187
+ const e = groError("config_error", "bad");
188
+ const fields = errorLogFields(e);
189
+ assert.strictEqual(fields.kind, "config_error");
190
+ assert.strictEqual(fields.message, "bad");
191
+ assert.strictEqual(fields.retryable, false);
192
+ assert.ok(fields.stack, "should include stack");
193
+ });
194
+
195
+ test("includes optional fields when present", () => {
196
+ const e = groError("provider_error", "fail", {
197
+ provider: "anthropic",
198
+ model: "claude-sonnet-4-20250514",
199
+ request_id: "req_abc",
200
+ latency_ms: 100,
201
+ });
202
+ const fields = errorLogFields(e);
203
+ assert.strictEqual(fields.provider, "anthropic");
204
+ assert.strictEqual(fields.model, "claude-sonnet-4-20250514");
205
+ assert.strictEqual(fields.request_id, "req_abc");
206
+ assert.strictEqual(fields.latency_ms, 100);
207
+ });
208
+
209
+ test("omits optional fields when not set", () => {
210
+ const e = groError("tool_error", "boom");
211
+ const fields = errorLogFields(e);
212
+ assert.strictEqual(fields.provider, undefined);
213
+ assert.strictEqual(fields.model, undefined);
214
+ assert.strictEqual(fields.request_id, undefined);
215
+ assert.strictEqual(fields.latency_ms, undefined);
216
+ });
217
+
218
+ test("resolves cause into cause_message and cause_stack", () => {
219
+ const cause = new Error("root cause");
220
+ const e = groError("mcp_error", "mcp failed", { cause });
221
+ const fields = errorLogFields(e);
222
+ assert.strictEqual(fields.cause_message, "root cause");
223
+ assert.ok(fields.cause_stack);
224
+ });
225
+
226
+ test("resolves non-Error cause via asError", () => {
227
+ const e = groError("provider_error", "fail", { cause: "string cause" });
228
+ const fields = errorLogFields(e);
229
+ assert.strictEqual(fields.cause_message, "string cause");
230
+ });
231
+
232
+ test("result is JSON-serializable", () => {
233
+ const e = groError("provider_error", "test", {
234
+ provider: "openai",
235
+ model: "gpt-4o",
236
+ request_id: "req_1",
237
+ latency_ms: 200,
238
+ cause: new Error("inner"),
239
+ });
240
+ const fields = errorLogFields(e);
241
+ const json = JSON.stringify(fields);
242
+ assert.ok(json.length > 0);
243
+ const parsed = JSON.parse(json);
244
+ assert.strictEqual(parsed.kind, "provider_error");
245
+ });
246
+ });