@tracemarketplace/shared 0.0.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.
Files changed (63) hide show
  1. package/dist/extractors/claude-code.d.ts +3 -0
  2. package/dist/extractors/claude-code.d.ts.map +1 -0
  3. package/dist/extractors/claude-code.js +158 -0
  4. package/dist/extractors/claude-code.js.map +1 -0
  5. package/dist/extractors/codex.d.ts +3 -0
  6. package/dist/extractors/codex.d.ts.map +1 -0
  7. package/dist/extractors/codex.js +192 -0
  8. package/dist/extractors/codex.js.map +1 -0
  9. package/dist/extractors/cursor.d.ts +3 -0
  10. package/dist/extractors/cursor.d.ts.map +1 -0
  11. package/dist/extractors/cursor.js +99 -0
  12. package/dist/extractors/cursor.js.map +1 -0
  13. package/dist/hash.d.ts +4 -0
  14. package/dist/hash.d.ts.map +1 -0
  15. package/dist/hash.js +13 -0
  16. package/dist/hash.js.map +1 -0
  17. package/dist/hash.test.d.ts +2 -0
  18. package/dist/hash.test.d.ts.map +1 -0
  19. package/dist/hash.test.js +67 -0
  20. package/dist/hash.test.js.map +1 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +9 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/scoring.d.ts +5 -0
  26. package/dist/scoring.d.ts.map +1 -0
  27. package/dist/scoring.js +114 -0
  28. package/dist/scoring.js.map +1 -0
  29. package/dist/scoring.test.d.ts +2 -0
  30. package/dist/scoring.test.d.ts.map +1 -0
  31. package/dist/scoring.test.js +157 -0
  32. package/dist/scoring.test.js.map +1 -0
  33. package/dist/types.d.ts +98 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +2 -0
  36. package/dist/types.js.map +1 -0
  37. package/dist/utils.d.ts +3 -0
  38. package/dist/utils.d.ts.map +1 -0
  39. package/dist/utils.js +11 -0
  40. package/dist/utils.js.map +1 -0
  41. package/dist/validators.d.ts +247 -0
  42. package/dist/validators.d.ts.map +1 -0
  43. package/dist/validators.js +36 -0
  44. package/dist/validators.js.map +1 -0
  45. package/dist/validators.test.d.ts +2 -0
  46. package/dist/validators.test.d.ts.map +1 -0
  47. package/dist/validators.test.js +52 -0
  48. package/dist/validators.test.js.map +1 -0
  49. package/package.json +42 -0
  50. package/src/extractors/claude-code.ts +178 -0
  51. package/src/extractors/codex.ts +208 -0
  52. package/src/extractors/cursor.ts +118 -0
  53. package/src/hash.test.ts +72 -0
  54. package/src/hash.ts +15 -0
  55. package/src/index.ts +8 -0
  56. package/src/scoring.test.ts +173 -0
  57. package/src/scoring.ts +149 -0
  58. package/src/types.ts +96 -0
  59. package/src/utils.ts +9 -0
  60. package/src/validators.test.ts +61 -0
  61. package/src/validators.ts +41 -0
  62. package/tsconfig.json +8 -0
  63. package/vitest.config.ts +8 -0
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "crypto";
2
+ import { hashString, computeContentHash } from "../hash.js";
3
+ import type {
4
+ NormalizedTrace,
5
+ Turn,
6
+ ContentBlock,
7
+ } from "../types.js";
8
+
9
+ export async function extractCodex(
10
+ rolloutFileBuffer: Buffer,
11
+ submittedBy = "unknown"
12
+ ): Promise<NormalizedTrace> {
13
+ const events: Record<string, unknown>[] = [];
14
+ for (const line of rolloutFileBuffer.toString("utf-8").split("\n")) {
15
+ if (!line.trim()) continue;
16
+ try { events.push(JSON.parse(line) as Record<string, unknown>); } catch {}
17
+ }
18
+
19
+ const sessionMetaPayload = (events.find(e => e["type"] === "session_meta")?.["payload"] ?? {}) as Record<string, unknown>;
20
+ const sessionId = (sessionMetaPayload["id"] as string | undefined) ?? randomUUID();
21
+ const cwd = (sessionMetaPayload["cwd"] as string | undefined) ?? null;
22
+ const cliVersion = (sessionMetaPayload["cli_version"] as string | undefined) ?? null;
23
+
24
+ const turns: Turn[] = [];
25
+
26
+ let inTask = false;
27
+ let currentTurnId: string | null = null;
28
+ let currentUserMessage: string | null = null;
29
+ let currentBlocks: ContentBlock[] = [];
30
+ let taskTimestamp: string | null = null;
31
+ let currentModel: string | null = null;
32
+
33
+ for (const event of events) {
34
+ const type = event["type"] as string;
35
+ const payload = (event["payload"] ?? {}) as Record<string, unknown>;
36
+ const timestamp = (event["timestamp"] as string | undefined) ?? null;
37
+
38
+ if (type === "event_msg" && payload["type"] === "task_started") {
39
+ inTask = true;
40
+ currentTurnId = (payload["turn_id"] as string | undefined) ?? randomUUID();
41
+ currentUserMessage = null;
42
+ currentBlocks = [];
43
+ taskTimestamp = timestamp;
44
+ continue;
45
+ }
46
+
47
+ if (type === "event_msg" && payload["type"] === "task_complete") {
48
+ if (inTask) {
49
+ if (currentUserMessage) {
50
+ turns.push({
51
+ turn_id: "user_" + currentTurnId,
52
+ parent_turn_id: null,
53
+ role: "user",
54
+ timestamp: taskTimestamp,
55
+ content: [{ type: "text", text: currentUserMessage }],
56
+ model: null,
57
+ usage: null,
58
+ source_metadata: { turn_id: currentTurnId },
59
+ });
60
+ }
61
+
62
+ const lastMsg = payload["last_agent_message"] as string | undefined;
63
+ if (lastMsg) {
64
+ const alreadyCaptured = currentBlocks.some(
65
+ b => b.type === "text" && (b as { type: "text"; text: string }).text === lastMsg
66
+ );
67
+ if (!alreadyCaptured) currentBlocks.push({ type: "text", text: lastMsg });
68
+ }
69
+
70
+ if (currentBlocks.length > 0) {
71
+ turns.push({
72
+ turn_id: currentTurnId ?? randomUUID(),
73
+ parent_turn_id: currentUserMessage ? "user_" + currentTurnId : null,
74
+ role: "assistant",
75
+ timestamp: taskTimestamp,
76
+ content: currentBlocks,
77
+ model: currentModel,
78
+ usage: null,
79
+ source_metadata: { turn_id: currentTurnId },
80
+ });
81
+ }
82
+ }
83
+ inTask = false;
84
+ currentTurnId = null;
85
+ currentUserMessage = null;
86
+ currentBlocks = [];
87
+ taskTimestamp = null;
88
+ currentModel = null;
89
+ continue;
90
+ }
91
+
92
+ if (!inTask) continue;
93
+
94
+ if (type === "turn_context") {
95
+ currentModel = (payload["model"] as string | undefined) ?? null;
96
+ continue;
97
+ }
98
+
99
+ if (type === "event_msg") {
100
+ switch (payload["type"] as string) {
101
+ case "user_message":
102
+ currentUserMessage = (payload["message"] as string | undefined) ?? "";
103
+ break;
104
+ case "agent_reasoning":
105
+ if (payload["text"]) currentBlocks.push({ type: "thinking", text: payload["text"] as string });
106
+ break;
107
+ case "agent_message":
108
+ if (payload["message"]) currentBlocks.push({ type: "text", text: payload["message"] as string });
109
+ break;
110
+ }
111
+ } else if (type === "response_item") {
112
+ switch (payload["type"] as string) {
113
+ case "function_call": {
114
+ const callId = (payload["call_id"] as string | undefined) ?? randomUUID();
115
+ let toolInput: Record<string, unknown> = {};
116
+ try { toolInput = JSON.parse(payload["arguments"] as string ?? "{}"); } catch {
117
+ toolInput = { raw: payload["arguments"] };
118
+ }
119
+ currentBlocks.push({
120
+ type: "tool_use",
121
+ tool_call_id: callId,
122
+ tool_name: (payload["name"] as string | undefined) ?? "unknown",
123
+ tool_input: toolInput,
124
+ });
125
+ break;
126
+ }
127
+ case "function_call_output": {
128
+ const output = payload["output"] as string | undefined;
129
+ const isError = typeof output === "string"
130
+ && output.includes("Process exited with code")
131
+ && !output.includes("code 0");
132
+ currentBlocks.push({
133
+ type: "tool_result",
134
+ tool_call_id: (payload["call_id"] as string | undefined) ?? "",
135
+ is_error: isError,
136
+ result_content: output ?? null,
137
+ exit_code: null,
138
+ });
139
+ break;
140
+ }
141
+ case "web_search_call": {
142
+ const action = payload["action"] as Record<string, unknown> | undefined;
143
+ currentBlocks.push({
144
+ type: "tool_use",
145
+ tool_call_id: randomUUID(),
146
+ tool_name: "web_search",
147
+ tool_input: { query: action?.["query"] ?? "", queries: action?.["queries"] ?? [] },
148
+ });
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ const allBlocks = turns.flatMap(t => t.content);
156
+ const toolCallCount = allBlocks.filter(b => b.type === "tool_use").length;
157
+ const hasFileChanges = allBlocks.some(
158
+ b => b.type === "tool_use" && ["write_file", "file_change", "create_file"].includes((b as { type: "tool_use"; tool_name: string }).tool_name)
159
+ );
160
+ const hasShellCommands = allBlocks.some(
161
+ b => b.type === "tool_use" && ["exec_command", "bash", "shell"].includes((b as { type: "tool_use"; tool_name: string }).tool_name)
162
+ );
163
+ const hasThinking = allBlocks.some(b => b.type === "thinking");
164
+ const startedAt = (sessionMetaPayload["timestamp"] as string | undefined) ?? events[0]?.["timestamp"] as string ?? new Date().toISOString();
165
+ const endedAt = events[events.length - 1]?.["timestamp"] as string ?? new Date().toISOString();
166
+
167
+ const partialTrace: Omit<NormalizedTrace, "trace_id"> = {
168
+ schema_version: "1.0",
169
+ source_tool: "codex_cli",
170
+ source_session_id: sessionId,
171
+ source_version: cliVersion,
172
+ submitted_by: submittedBy,
173
+ submitted_at: new Date().toISOString(),
174
+ extracted_at: new Date().toISOString(),
175
+ git_branch: null,
176
+ cwd_hash: cwd ? hashString(cwd) : null,
177
+ working_language: null,
178
+ started_at: startedAt,
179
+ ended_at: endedAt,
180
+ turns,
181
+ turn_count: turns.length,
182
+ tool_call_count: toolCallCount,
183
+ has_tool_calls: toolCallCount > 0,
184
+ has_thinking_blocks: hasThinking,
185
+ has_file_changes: hasFileChanges,
186
+ has_shell_commands: hasShellCommands,
187
+ total_input_tokens: null,
188
+ total_output_tokens: null,
189
+ total_cache_read_tokens: null,
190
+ content_fidelity: "full",
191
+ env_state: {
192
+ git_branch: null,
193
+ inferred_file_tree: null,
194
+ inferred_changed_files: null,
195
+ inferred_error_files: null,
196
+ shell_exit_codes: null,
197
+ open_files_in_editor: null,
198
+ extraction_method: "passive",
199
+ },
200
+ score: null,
201
+ raw_r2_key: "",
202
+ normalized_r2_key: "",
203
+ };
204
+
205
+ const contentHash = computeContentHash(partialTrace as NormalizedTrace);
206
+ const traceId = hashString("codex_cli" + sessionId + contentHash);
207
+ return { ...partialTrace, trace_id: traceId };
208
+ }
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from "crypto";
2
+ import { hashString, computeContentHash } from "../hash.js";
3
+ import type { NormalizedTrace, Turn, TokenUsage } from "../types.js";
4
+
5
+ export async function extractCursor(
6
+ dbPath: string,
7
+ sessionId: string,
8
+ submittedBy = "unknown"
9
+ ): Promise<NormalizedTrace> {
10
+ const Database = (await import("better-sqlite3")).default;
11
+ const db = new Database(dbPath, { readonly: true });
12
+
13
+ try {
14
+ const composerRow = db
15
+ .prepare("SELECT value FROM cursorDiskKV WHERE key = ?")
16
+ .get(`composerData:${sessionId}`) as { value: string } | undefined;
17
+ if (!composerRow) throw new Error(`Session ${sessionId} not found in cursor DB`);
18
+
19
+ const composerData = JSON.parse(composerRow.value);
20
+ const headers: any[] = composerData.fullConversationHeadersOnly ?? [];
21
+
22
+ const turns: Turn[] = [];
23
+ const openFiles: string[] = [];
24
+
25
+ for (const header of headers) {
26
+ const bubbleId = header.bubbleId ?? header.id;
27
+ if (!bubbleId) continue;
28
+
29
+ const blobRow = db
30
+ .prepare("SELECT value FROM cursorDiskKV WHERE key = ?")
31
+ .get(`agentKv:blob:${bubbleId}`) as { value: string } | undefined;
32
+ if (!blobRow) continue;
33
+
34
+ const blob = JSON.parse(blobRow.value);
35
+ const role: "user" | "assistant" =
36
+ blob.type === "user" || blob.role === "user" ? "user" : "assistant";
37
+ const text: string = blob.text ?? blob.content ?? blob.message ?? "";
38
+
39
+ if (blob.context?.openFiles) {
40
+ openFiles.push(
41
+ ...blob.context.openFiles.map((f: any) => (typeof f === "string" ? f : f.path ?? ""))
42
+ );
43
+ }
44
+
45
+ const tokenUsage: TokenUsage | null = blob.usage
46
+ ? {
47
+ input_tokens: blob.usage.promptTokens ?? 0,
48
+ output_tokens: blob.usage.completionTokens ?? 0,
49
+ cache_read_input_tokens: null,
50
+ cache_creation_input_tokens: null,
51
+ reasoning_tokens: null,
52
+ }
53
+ : null;
54
+
55
+ turns.push({
56
+ turn_id: bubbleId,
57
+ parent_turn_id: null,
58
+ role,
59
+ timestamp: blob.createdAt ?? header.createdAt ?? null,
60
+ content: [{ type: "text", text }],
61
+ model: blob.model ?? null,
62
+ usage: tokenUsage,
63
+ source_metadata: { bubbleId, type: blob.type },
64
+ });
65
+ }
66
+
67
+ db.close();
68
+
69
+ const startedAt = turns[0]?.timestamp ?? new Date().toISOString();
70
+ const endedAt = turns[turns.length - 1]?.timestamp ?? new Date().toISOString();
71
+
72
+ const partialTrace: Omit<NormalizedTrace, "trace_id"> = {
73
+ schema_version: "1.0",
74
+ source_tool: "cursor",
75
+ source_session_id: sessionId,
76
+ source_version: null,
77
+ submitted_by: submittedBy,
78
+ submitted_at: new Date().toISOString(),
79
+ extracted_at: new Date().toISOString(),
80
+ git_branch: null,
81
+ cwd_hash: null,
82
+ working_language: null,
83
+ started_at: startedAt,
84
+ ended_at: endedAt,
85
+ turns,
86
+ turn_count: turns.length,
87
+ tool_call_count: 0,
88
+ has_tool_calls: false,
89
+ has_thinking_blocks: false,
90
+ has_file_changes: false,
91
+ has_shell_commands: false,
92
+ total_input_tokens: null,
93
+ total_output_tokens: null,
94
+ total_cache_read_tokens: null,
95
+ content_fidelity: "chat_only",
96
+ env_state: {
97
+ git_branch: null,
98
+ inferred_file_tree: null,
99
+ inferred_changed_files: null,
100
+ inferred_error_files: null,
101
+ shell_exit_codes: null,
102
+ open_files_in_editor: openFiles.length > 0 ? openFiles : null,
103
+ extraction_method: "passive",
104
+ },
105
+ score: null,
106
+ raw_r2_key: "",
107
+ normalized_r2_key: "",
108
+ };
109
+
110
+ const contentHash = computeContentHash(partialTrace as NormalizedTrace);
111
+ const traceId = hashString("cursor" + sessionId + contentHash);
112
+
113
+ return { ...partialTrace, trace_id: traceId };
114
+ } catch (err) {
115
+ db.close();
116
+ throw err;
117
+ }
118
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeContentHash } from "./hash.js";
3
+ import type { NormalizedTrace } from "./types.js";
4
+
5
+ function makeTrace(overrides: Partial<NormalizedTrace> = {}): NormalizedTrace {
6
+ return {
7
+ trace_id: "test-id",
8
+ schema_version: "1.0",
9
+ source_tool: "claude_code",
10
+ source_session_id: "session-abc",
11
+ source_version: null,
12
+ submitted_by: "user1",
13
+ submitted_at: "2024-01-01T00:00:00Z",
14
+ extracted_at: "2024-01-01T00:00:00Z",
15
+ git_branch: null,
16
+ cwd_hash: null,
17
+ working_language: null,
18
+ started_at: "2024-01-01T00:00:00Z",
19
+ ended_at: "2024-01-01T00:01:00Z",
20
+ turns: [],
21
+ turn_count: 0,
22
+ tool_call_count: 0,
23
+ has_tool_calls: false,
24
+ has_thinking_blocks: false,
25
+ has_file_changes: false,
26
+ has_shell_commands: false,
27
+ total_input_tokens: null,
28
+ total_output_tokens: null,
29
+ total_cache_read_tokens: null,
30
+ content_fidelity: "full",
31
+ env_state: null,
32
+ score: null,
33
+ raw_r2_key: "",
34
+ normalized_r2_key: "",
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("computeContentHash", () => {
40
+ it("returns the same hash for identical input", () => {
41
+ const trace = makeTrace();
42
+ expect(computeContentHash(trace)).toBe(computeContentHash(trace));
43
+ });
44
+
45
+ it("returns different hashes for different session_ids", () => {
46
+ const a = makeTrace({ source_session_id: "session-aaa" });
47
+ const b = makeTrace({ source_session_id: "session-bbb" });
48
+ expect(computeContentHash(a)).not.toBe(computeContentHash(b));
49
+ });
50
+
51
+ it("returns different hashes for different turns", () => {
52
+ const a = makeTrace({ turns: [] });
53
+ const b = makeTrace({
54
+ turns: [{
55
+ turn_id: "t1",
56
+ parent_turn_id: null,
57
+ role: "user",
58
+ timestamp: null,
59
+ content: [{ type: "text", text: "hello" }],
60
+ model: null,
61
+ usage: null,
62
+ source_metadata: {},
63
+ }],
64
+ });
65
+ expect(computeContentHash(a)).not.toBe(computeContentHash(b));
66
+ });
67
+
68
+ it("output is a 64-char hex string", () => {
69
+ const hash = computeContentHash(makeTrace());
70
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
71
+ });
72
+ });
package/src/hash.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { createHash } from "crypto";
2
+ import type { NormalizedTrace } from "./types.js";
3
+
4
+ export function computeContentHash(trace: NormalizedTrace): string {
5
+ const content = JSON.stringify({
6
+ source_tool: trace.source_tool,
7
+ source_session_id: trace.source_session_id,
8
+ turns: trace.turns,
9
+ });
10
+ return createHash("sha256").update(content).digest("hex");
11
+ }
12
+
13
+ export function hashString(s: string): string {
14
+ return createHash("sha256").update(s).digest("hex");
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./hash.js";
3
+ export * from "./scoring.js";
4
+ export * from "./utils.js";
5
+ export * from "./validators.js";
6
+ export { extractClaudeCode } from "./extractors/claude-code.js";
7
+ export { extractCodex } from "./extractors/codex.js";
8
+ export { extractCursor } from "./extractors/cursor.js";
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectFailureModes, checkCompleteness, scoreTrace } from "./scoring.js";
3
+ import type { NormalizedTrace, Turn, ContentBlock } from "./types.js";
4
+
5
+ function makeTrace(overrides: Partial<NormalizedTrace> = {}): NormalizedTrace {
6
+ return {
7
+ trace_id: "test-id",
8
+ schema_version: "1.0",
9
+ source_tool: "claude_code",
10
+ source_session_id: "session-abc",
11
+ source_version: null,
12
+ submitted_by: "user1",
13
+ submitted_at: "2024-01-01T00:00:00Z",
14
+ extracted_at: "2024-01-01T00:00:00Z",
15
+ git_branch: null,
16
+ cwd_hash: null,
17
+ working_language: null,
18
+ started_at: "2024-01-01T00:00:00Z",
19
+ ended_at: "2024-01-01T00:01:00Z",
20
+ turns: [],
21
+ turn_count: 0,
22
+ tool_call_count: 0,
23
+ has_tool_calls: false,
24
+ has_thinking_blocks: false,
25
+ has_file_changes: false,
26
+ has_shell_commands: false,
27
+ total_input_tokens: null,
28
+ total_output_tokens: null,
29
+ total_cache_read_tokens: null,
30
+ content_fidelity: "full",
31
+ env_state: null,
32
+ score: null,
33
+ raw_r2_key: "",
34
+ normalized_r2_key: "",
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function makeTurn(role: "user" | "assistant", content: ContentBlock[]): Turn {
40
+ return {
41
+ turn_id: Math.random().toString(36).slice(2),
42
+ parent_turn_id: null,
43
+ role,
44
+ timestamp: null,
45
+ content,
46
+ model: null,
47
+ usage: null,
48
+ source_metadata: {},
49
+ };
50
+ }
51
+
52
+ describe("detectFailureModes", () => {
53
+ it("empty turns → no_failure", () => {
54
+ const result = detectFailureModes(makeTrace({ turns: [] }));
55
+ expect(result).toEqual(["no_failure"]);
56
+ });
57
+
58
+ it("tool_result with is_error → tool_call_failure", () => {
59
+ const turns = [
60
+ makeTurn("user", [{ type: "tool_result", tool_call_id: "t1", is_error: true, result_content: "err", exit_code: 1 }]),
61
+ ];
62
+ const result = detectFailureModes(makeTrace({ turns }));
63
+ expect(result).toContain("tool_call_failure");
64
+ });
65
+
66
+ it("same tool 3× consecutive → repeated_tool_calls", () => {
67
+ const toolUse = (n: number): ContentBlock => ({
68
+ type: "tool_use",
69
+ tool_call_id: `t${n}`,
70
+ tool_name: "bash",
71
+ tool_input: {},
72
+ });
73
+ const turns = [
74
+ makeTurn("assistant", [toolUse(1), toolUse(2), toolUse(3)]),
75
+ ];
76
+ const result = detectFailureModes(makeTrace({ turns }));
77
+ expect(result).toContain("repeated_tool_calls");
78
+ });
79
+
80
+ it("context window text → context_limit_approached", () => {
81
+ const turns = [
82
+ makeTurn("assistant", [{ type: "text", text: "You have reached the context limit of this session." }]),
83
+ ];
84
+ const result = detectFailureModes(makeTrace({ turns }));
85
+ expect(result).toContain("context_limit_approached");
86
+ });
87
+
88
+ it("final turns all errors → catastrophic_failure", () => {
89
+ const errResult: ContentBlock = { type: "tool_result", tool_call_id: "t1", is_error: true, result_content: "fail", exit_code: 1 };
90
+ const turns = [
91
+ makeTurn("user", [errResult]),
92
+ makeTurn("user", [errResult]),
93
+ ];
94
+ const result = detectFailureModes(makeTrace({ turns }));
95
+ expect(result).toContain("catastrophic_failure");
96
+ });
97
+
98
+ it("tool errors + later recovery text → graceful_recovery", () => {
99
+ const errResult: ContentBlock = { type: "tool_result", tool_call_id: "t1", is_error: true, result_content: "fail", exit_code: 1 };
100
+ const turns = [
101
+ makeTurn("user", [errResult]),
102
+ makeTurn("assistant", [{ type: "text", text: "Let me try a different approach instead." }]),
103
+ ];
104
+ const result = detectFailureModes(makeTrace({ turns }));
105
+ expect(result).toContain("graceful_recovery");
106
+ });
107
+ });
108
+
109
+ describe("checkCompleteness", () => {
110
+ it("no turns → malformed", () => {
111
+ expect(checkCompleteness(makeTrace({ turns: [] }))).toBe("malformed");
112
+ });
113
+
114
+ it("turn with empty content → malformed", () => {
115
+ const turns = [makeTurn("assistant", [])];
116
+ expect(checkCompleteness(makeTrace({ turns }))).toBe("malformed");
117
+ });
118
+
119
+ it("1 user turn → incomplete", () => {
120
+ const turns = [makeTurn("user", [{ type: "text", text: "hello" }])];
121
+ expect(checkCompleteness(makeTrace({ turns }))).toBe("incomplete");
122
+ });
123
+
124
+ it("2+ turns ending with assistant text → complete", () => {
125
+ const turns = [
126
+ makeTurn("user", [{ type: "text", text: "hello" }]),
127
+ makeTurn("assistant", [{ type: "text", text: "world" }]),
128
+ ];
129
+ expect(checkCompleteness(makeTrace({ turns }))).toBe("complete");
130
+ });
131
+ });
132
+
133
+ describe("scoreTrace", () => {
134
+ it("malformed trace → near-zero payout", () => {
135
+ const score = scoreTrace(makeTrace({ turns: [], content_fidelity: "chat_only" }));
136
+ expect(score.completeness).toBe("malformed");
137
+ expect(score.payout_cents).toBeLessThan(100);
138
+ });
139
+
140
+ it("graceful_recovery + tool_call_failure → bonuses stack", () => {
141
+ const baseScore = scoreTrace(makeTrace({ turns: [], content_fidelity: "full" }));
142
+ const errResult: ContentBlock = { type: "tool_result", tool_call_id: "t1", is_error: true, result_content: "fail", exit_code: 1 };
143
+ const turns = [
144
+ makeTurn("user", [errResult]),
145
+ makeTurn("assistant", [{ type: "text", text: "Let me try a different approach instead." }]),
146
+ ];
147
+ const bonusScore = scoreTrace(makeTrace({ turns, content_fidelity: "full" }));
148
+ expect(bonusScore.payout_cents).toBeGreaterThan(baseScore.payout_cents);
149
+ expect(bonusScore.failure_modes).toContain("graceful_recovery");
150
+ expect(bonusScore.failure_modes).toContain("tool_call_failure");
151
+ });
152
+
153
+ it("total clamps to [0, 1]", () => {
154
+ const errResult: ContentBlock = { type: "tool_result", tool_call_id: "t1", is_error: true, result_content: "fail", exit_code: 1 };
155
+ const turns = [
156
+ makeTurn("user", [errResult]),
157
+ makeTurn("assistant", [{ type: "text", text: "Let me try a different approach instead." }]),
158
+ ];
159
+ const score = scoreTrace(makeTrace({ turns, content_fidelity: "full", total_input_tokens: 1000000, total_output_tokens: 1000000 }));
160
+ expect(score.total).toBeGreaterThanOrEqual(0);
161
+ expect(score.total).toBeLessThanOrEqual(1);
162
+ });
163
+
164
+ it("payout_cents = round(total * 500) clamped to 500", () => {
165
+ const turns = [
166
+ makeTurn("user", [{ type: "text", text: "hello" }]),
167
+ makeTurn("assistant", [{ type: "text", text: "world" }]),
168
+ ];
169
+ const score = scoreTrace(makeTrace({ turns, content_fidelity: "full" }));
170
+ const expected = Math.min(500, Math.round(score.total * 500));
171
+ expect(score.payout_cents).toBe(expected);
172
+ });
173
+ });