@tekyzinc/gsd-t 2.74.13 → 3.10.10
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.
- package/CHANGELOG.md +165 -0
- package/README.md +117 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -0
- package/bin/gsd-t-unattended-platform.js +381 -0
- package/bin/gsd-t-unattended-safety.js +766 -0
- package/bin/gsd-t-unattended.js +1259 -0
- package/bin/gsd-t.js +723 -19
- package/bin/handoff-lock.js +249 -0
- package/bin/headless-auto-spawn.js +328 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +22 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +86 -1
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-unattended-stop.md +83 -0
- package/commands/gsd-t-unattended-watch.md +290 -0
- package/commands/gsd-t-unattended.md +414 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +69 -0
- package/docs/architecture.md +176 -4
- package/docs/infrastructure.md +221 -0
- package/docs/methodology.md +44 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +95 -0
- package/docs/unattended-windows-caveats.md +245 -0
- package/package.json +2 -2
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +17 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- package/bin/task-counter.cjs +0 -161
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript-parser.js
|
|
3
|
+
*
|
|
4
|
+
* Parses a Claude Code transcript JSONL file and reconstructs it into the
|
|
5
|
+
* `{ system, messages }` shape expected by Anthropic's `count_tokens` endpoint.
|
|
6
|
+
*
|
|
7
|
+
* ------------------------------------------------------------------------
|
|
8
|
+
* Claude Code transcript JSONL format (UNDOCUMENTED — observed empirically
|
|
9
|
+
* from real session files under ~/.claude/projects/<slug>/*.jsonl).
|
|
10
|
+
*
|
|
11
|
+
* Each line is a single JSON object. Observed top-level `type` values:
|
|
12
|
+
*
|
|
13
|
+
* - "user" — user turn. `message` = { role: "user", content: STRING|ARRAY }.
|
|
14
|
+
* When `content` is an array, it contains blocks like
|
|
15
|
+
* { type: "tool_result", tool_use_id, content, is_error? }.
|
|
16
|
+
* `content` on the tool_result block can itself be a string
|
|
17
|
+
* or an array of text/image blocks.
|
|
18
|
+
*
|
|
19
|
+
* - "assistant" — assistant turn. `message` = { role, content: ARRAY, model,
|
|
20
|
+
* id, stop_reason, usage, ... }. `content` blocks observed:
|
|
21
|
+
* { type: "text", text }
|
|
22
|
+
* { type: "thinking", thinking, signature }
|
|
23
|
+
* { type: "tool_use", id, name, input, caller? }
|
|
24
|
+
*
|
|
25
|
+
* - "system" — system/tool hook metadata (subtype, hookInfos, etc.).
|
|
26
|
+
* No message payload; SKIPPED.
|
|
27
|
+
*
|
|
28
|
+
* - "summary" — session metadata; SKIPPED.
|
|
29
|
+
* - "attachment" — file/image attachment metadata; SKIPPED for count purposes
|
|
30
|
+
* (count_tokens only needs role/content message blocks).
|
|
31
|
+
* - "file-history-snapshot" — editor file state; SKIPPED.
|
|
32
|
+
* - "permission-mode" — session flag; SKIPPED.
|
|
33
|
+
* - "queue-operation" — internal; SKIPPED.
|
|
34
|
+
* - "last-prompt" — internal; SKIPPED.
|
|
35
|
+
* - (any other / unknown) — SKIPPED with a no-op (forward-compatible).
|
|
36
|
+
*
|
|
37
|
+
* Example scrubbed shapes (content replaced with "..."):
|
|
38
|
+
*
|
|
39
|
+
* {"type":"user","message":{"role":"user","content":"..."},"uuid":"...","sessionId":"..."}
|
|
40
|
+
*
|
|
41
|
+
* {"type":"user","message":{"role":"user","content":[
|
|
42
|
+
* {"type":"tool_result","tool_use_id":"toolu_01...","content":"...","is_error":false}
|
|
43
|
+
* ]},"uuid":"..."}
|
|
44
|
+
*
|
|
45
|
+
* {"type":"assistant","message":{"role":"assistant","content":[
|
|
46
|
+
* {"type":"text","text":"..."},
|
|
47
|
+
* {"type":"tool_use","id":"toolu_01...","name":"Read","input":{"file_path":"..."}}
|
|
48
|
+
* ],"model":"claude-...","usage":{...}}}
|
|
49
|
+
*
|
|
50
|
+
* ------------------------------------------------------------------------
|
|
51
|
+
* count_tokens request body shape:
|
|
52
|
+
*
|
|
53
|
+
* {
|
|
54
|
+
* "system": "",
|
|
55
|
+
* "messages": [
|
|
56
|
+
* { "role": "user"|"assistant", "content": [ {type, ...}, ... ] },
|
|
57
|
+
* ...
|
|
58
|
+
* ]
|
|
59
|
+
* }
|
|
60
|
+
*
|
|
61
|
+
* The parser:
|
|
62
|
+
* - Streams the file line-by-line via readline (transcripts can be large).
|
|
63
|
+
* - Keeps only user/assistant message content in insertion order.
|
|
64
|
+
* - Filters out assistant `thinking` blocks (not accepted by count_tokens).
|
|
65
|
+
* - Preserves the natural pairing of assistant `tool_use` blocks with their
|
|
66
|
+
* later user `tool_result` blocks — they already appear in chronological
|
|
67
|
+
* order in the JSONL, so pairing is implicit (same tool_use_id).
|
|
68
|
+
* - On unreadable file or a JSON.parse throw at the top-level: returns null.
|
|
69
|
+
* Malformed INDIVIDUAL lines inside an otherwise valid file are skipped.
|
|
70
|
+
*
|
|
71
|
+
* Zero external deps — built-in `fs`, `readline` only.
|
|
72
|
+
*
|
|
73
|
+
* @module scripts/context-meter/transcript-parser
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
"use strict";
|
|
77
|
+
|
|
78
|
+
const fs = require("fs");
|
|
79
|
+
const readline = require("readline");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse a Claude Code transcript JSONL file.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} transcriptPath - absolute path to a Claude Code transcript .jsonl
|
|
85
|
+
* @returns {Promise<{system: string, messages: Array<{role: string, content: Array}>} | null>}
|
|
86
|
+
* Resolves to the reconstructed body, or `null` on unreadable file /
|
|
87
|
+
* catastrophic parse failure. Caller treats `null` as "bail out, fail open".
|
|
88
|
+
*/
|
|
89
|
+
async function parseTranscript(transcriptPath) {
|
|
90
|
+
if (typeof transcriptPath !== "string" || transcriptPath.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Existence/readability check before opening a stream — readline errors on
|
|
95
|
+
// ENOENT are awkward to catch cleanly.
|
|
96
|
+
try {
|
|
97
|
+
fs.accessSync(transcriptPath, fs.constants.R_OK);
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const messages = [];
|
|
103
|
+
let system = "";
|
|
104
|
+
|
|
105
|
+
let stream;
|
|
106
|
+
try {
|
|
107
|
+
stream = fs.createReadStream(transcriptPath, { encoding: "utf8" });
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: stream,
|
|
114
|
+
crlfDelay: Infinity,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for await (const rawLine of rl) {
|
|
119
|
+
const line = rawLine.trim();
|
|
120
|
+
if (line.length === 0) continue;
|
|
121
|
+
|
|
122
|
+
let evt;
|
|
123
|
+
try {
|
|
124
|
+
evt = JSON.parse(line);
|
|
125
|
+
} catch (_) {
|
|
126
|
+
// Malformed line in the middle — skip, keep going.
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!evt || typeof evt !== "object") continue;
|
|
131
|
+
|
|
132
|
+
const type = evt.type;
|
|
133
|
+
|
|
134
|
+
if (type === "user" || type === "assistant") {
|
|
135
|
+
const msg = evt.message;
|
|
136
|
+
if (!msg || typeof msg !== "object") continue;
|
|
137
|
+
|
|
138
|
+
const role = msg.role || type;
|
|
139
|
+
const content = normalizeContent(msg.content, role);
|
|
140
|
+
if (content === null) continue;
|
|
141
|
+
|
|
142
|
+
messages.push({ role, content });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// All other event types are skipped (summary, system, attachment,
|
|
147
|
+
// file-history-snapshot, permission-mode, queue-operation, last-prompt,
|
|
148
|
+
// and any unknown future type).
|
|
149
|
+
}
|
|
150
|
+
} catch (_) {
|
|
151
|
+
// Stream read error mid-flight — bail out fail-open.
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { system, messages };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Normalize a message.content value into the array-of-blocks shape expected
|
|
160
|
+
* by count_tokens. Returns null if the content is unusable.
|
|
161
|
+
*
|
|
162
|
+
* - String content → [{ type: "text", text: content }]
|
|
163
|
+
* - Array content → filtered copy retaining only supported block types
|
|
164
|
+
* - Anything else → null
|
|
165
|
+
*
|
|
166
|
+
* Blocks dropped:
|
|
167
|
+
* - assistant "thinking" blocks (not part of count_tokens message schema)
|
|
168
|
+
* - blocks missing their own `type` field
|
|
169
|
+
*
|
|
170
|
+
* Blocks kept:
|
|
171
|
+
* - text → { type, text }
|
|
172
|
+
* - tool_use → { type, id, name, input }
|
|
173
|
+
* - tool_result→ { type, tool_use_id, content, (is_error) }
|
|
174
|
+
* - image → { type, source } (pass-through)
|
|
175
|
+
* - anything else with a type string → passed through untouched (forward-compat)
|
|
176
|
+
*/
|
|
177
|
+
function normalizeContent(content, _role) {
|
|
178
|
+
if (typeof content === "string") {
|
|
179
|
+
return [{ type: "text", text: content }];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!Array.isArray(content)) return null;
|
|
183
|
+
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const block of content) {
|
|
186
|
+
if (!block || typeof block !== "object") continue;
|
|
187
|
+
const t = block.type;
|
|
188
|
+
if (typeof t !== "string") continue;
|
|
189
|
+
|
|
190
|
+
if (t === "thinking") continue;
|
|
191
|
+
|
|
192
|
+
if (t === "text") {
|
|
193
|
+
if (typeof block.text === "string") {
|
|
194
|
+
out.push({ type: "text", text: block.text });
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (t === "tool_use") {
|
|
200
|
+
// Must have id, name, input to be meaningful.
|
|
201
|
+
if (typeof block.id !== "string" || typeof block.name !== "string") continue;
|
|
202
|
+
out.push({
|
|
203
|
+
type: "tool_use",
|
|
204
|
+
id: block.id,
|
|
205
|
+
name: block.name,
|
|
206
|
+
input: block.input != null ? block.input : {},
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (t === "tool_result") {
|
|
212
|
+
if (typeof block.tool_use_id !== "string") continue;
|
|
213
|
+
const resultBlock = {
|
|
214
|
+
type: "tool_result",
|
|
215
|
+
tool_use_id: block.tool_use_id,
|
|
216
|
+
content: normalizeToolResultContent(block.content),
|
|
217
|
+
};
|
|
218
|
+
if (block.is_error === true) resultBlock.is_error = true;
|
|
219
|
+
out.push(resultBlock);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Unknown but typed block — pass through minimally (forward-compat).
|
|
224
|
+
out.push({ ...block });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (out.length === 0) return null;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize the `content` field of a tool_result block. It may itself be a
|
|
233
|
+
* string or an array of blocks (text / image). count_tokens accepts either
|
|
234
|
+
* form, but we normalize string to array for consistency.
|
|
235
|
+
*/
|
|
236
|
+
function normalizeToolResultContent(content) {
|
|
237
|
+
if (typeof content === "string") return content;
|
|
238
|
+
if (!Array.isArray(content)) return "";
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const b of content) {
|
|
241
|
+
if (!b || typeof b !== "object") continue;
|
|
242
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
243
|
+
out.push({ type: "text", text: b.text });
|
|
244
|
+
} else if (b.type === "image" && b.source) {
|
|
245
|
+
out.push({ type: "image", source: b.source });
|
|
246
|
+
}
|
|
247
|
+
// other inner types dropped
|
|
248
|
+
}
|
|
249
|
+
return out.length > 0 ? out : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { parseTranscript };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { test } = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
|
|
9
|
+
const { parseTranscript } = require("./transcript-parser");
|
|
10
|
+
|
|
11
|
+
/* ----------------------------- fixture helpers ---------------------------- */
|
|
12
|
+
|
|
13
|
+
function mkTmpFile(lines) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tp-"));
|
|
15
|
+
const file = path.join(dir, "transcript.jsonl");
|
|
16
|
+
const body = (lines || []).map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n");
|
|
17
|
+
fs.writeFileSync(file, body);
|
|
18
|
+
return { dir, file };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cleanup(dir) {
|
|
22
|
+
try {
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
} catch (_) {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* --------------------------------- tests ---------------------------------- */
|
|
30
|
+
|
|
31
|
+
test("nonexistent file → returns null", async () => {
|
|
32
|
+
const got = await parseTranscript("/definitely/not/a/real/path/xyz-9999.jsonl");
|
|
33
|
+
assert.equal(got, null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("empty path / non-string → returns null", async () => {
|
|
37
|
+
assert.equal(await parseTranscript(""), null);
|
|
38
|
+
assert.equal(await parseTranscript(null), null);
|
|
39
|
+
assert.equal(await parseTranscript(undefined), null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("empty file → returns { system:'', messages:[] }", async () => {
|
|
43
|
+
const { dir, file } = mkTmpFile([]);
|
|
44
|
+
try {
|
|
45
|
+
const got = await parseTranscript(file);
|
|
46
|
+
assert.deepEqual(got, { system: "", messages: [] });
|
|
47
|
+
} finally {
|
|
48
|
+
cleanup(dir);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("file with only unknown event types → { system:'', messages:[] }", async () => {
|
|
53
|
+
const { dir, file } = mkTmpFile([
|
|
54
|
+
{ type: "summary", foo: "bar" },
|
|
55
|
+
{ type: "system", subtype: "hook", hookInfos: [] },
|
|
56
|
+
{ type: "attachment", attachment: { name: "x.png" } },
|
|
57
|
+
{ type: "permission-mode", permissionMode: "default" },
|
|
58
|
+
{ type: "queue-operation", operation: "push" },
|
|
59
|
+
{ type: "some-future-type", foo: 1 },
|
|
60
|
+
]);
|
|
61
|
+
try {
|
|
62
|
+
const got = await parseTranscript(file);
|
|
63
|
+
assert.deepEqual(got, { system: "", messages: [] });
|
|
64
|
+
} finally {
|
|
65
|
+
cleanup(dir);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("normal conversation — string-content user + text assistant", async () => {
|
|
70
|
+
const { dir, file } = mkTmpFile([
|
|
71
|
+
{ type: "summary", foo: "bar" },
|
|
72
|
+
{
|
|
73
|
+
type: "user",
|
|
74
|
+
message: { role: "user", content: "Hello, Claude." },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "assistant",
|
|
78
|
+
message: {
|
|
79
|
+
role: "assistant",
|
|
80
|
+
content: [
|
|
81
|
+
{ type: "thinking", thinking: "secret reasoning", signature: "abc" },
|
|
82
|
+
{ type: "text", text: "Hi there!" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
try {
|
|
88
|
+
const got = await parseTranscript(file);
|
|
89
|
+
assert.equal(got.system, "");
|
|
90
|
+
assert.equal(got.messages.length, 2);
|
|
91
|
+
|
|
92
|
+
assert.deepEqual(got.messages[0], {
|
|
93
|
+
role: "user",
|
|
94
|
+
content: [{ type: "text", text: "Hello, Claude." }],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// thinking block must be stripped
|
|
98
|
+
assert.deepEqual(got.messages[1], {
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [{ type: "text", text: "Hi there!" }],
|
|
101
|
+
});
|
|
102
|
+
} finally {
|
|
103
|
+
cleanup(dir);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("tool_use / tool_result pairing by tool_use_id preserved in order", async () => {
|
|
108
|
+
const TOOL_ID = "toolu_01ABC";
|
|
109
|
+
const { dir, file } = mkTmpFile([
|
|
110
|
+
{
|
|
111
|
+
type: "assistant",
|
|
112
|
+
message: {
|
|
113
|
+
role: "assistant",
|
|
114
|
+
content: [
|
|
115
|
+
{ type: "text", text: "Let me check." },
|
|
116
|
+
{
|
|
117
|
+
type: "tool_use",
|
|
118
|
+
id: TOOL_ID,
|
|
119
|
+
name: "Read",
|
|
120
|
+
input: { file_path: "/tmp/x" },
|
|
121
|
+
caller: "ignored",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "user",
|
|
128
|
+
message: {
|
|
129
|
+
role: "user",
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "tool_result",
|
|
133
|
+
tool_use_id: TOOL_ID,
|
|
134
|
+
content: "file contents here",
|
|
135
|
+
is_error: false,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: "assistant",
|
|
142
|
+
message: { role: "assistant", content: [{ type: "text", text: "Done." }] },
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
try {
|
|
146
|
+
const got = await parseTranscript(file);
|
|
147
|
+
assert.equal(got.messages.length, 3);
|
|
148
|
+
|
|
149
|
+
// assistant turn: text + tool_use (caller stripped, input preserved)
|
|
150
|
+
assert.equal(got.messages[0].role, "assistant");
|
|
151
|
+
assert.equal(got.messages[0].content.length, 2);
|
|
152
|
+
assert.deepEqual(got.messages[0].content[0], { type: "text", text: "Let me check." });
|
|
153
|
+
assert.deepEqual(got.messages[0].content[1], {
|
|
154
|
+
type: "tool_use",
|
|
155
|
+
id: TOOL_ID,
|
|
156
|
+
name: "Read",
|
|
157
|
+
input: { file_path: "/tmp/x" },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// user turn with tool_result, id matches, content normalized to string
|
|
161
|
+
assert.equal(got.messages[1].role, "user");
|
|
162
|
+
assert.equal(got.messages[1].content.length, 1);
|
|
163
|
+
const tr = got.messages[1].content[0];
|
|
164
|
+
assert.equal(tr.type, "tool_result");
|
|
165
|
+
assert.equal(tr.tool_use_id, TOOL_ID);
|
|
166
|
+
assert.equal(tr.content, "file contents here");
|
|
167
|
+
assert.equal(tr.is_error, undefined); // false is not preserved
|
|
168
|
+
|
|
169
|
+
// final assistant
|
|
170
|
+
assert.equal(got.messages[2].role, "assistant");
|
|
171
|
+
assert.deepEqual(got.messages[2].content, [{ type: "text", text: "Done." }]);
|
|
172
|
+
} finally {
|
|
173
|
+
cleanup(dir);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("tool_result with array content (text blocks) is normalized", async () => {
|
|
178
|
+
const { dir, file } = mkTmpFile([
|
|
179
|
+
{
|
|
180
|
+
type: "user",
|
|
181
|
+
message: {
|
|
182
|
+
role: "user",
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: "tool_result",
|
|
186
|
+
tool_use_id: "toolu_02",
|
|
187
|
+
content: [
|
|
188
|
+
{ type: "text", text: "line 1" },
|
|
189
|
+
{ type: "text", text: "line 2" },
|
|
190
|
+
{ type: "weird", value: 3 },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
try {
|
|
198
|
+
const got = await parseTranscript(file);
|
|
199
|
+
assert.equal(got.messages.length, 1);
|
|
200
|
+
const tr = got.messages[0].content[0];
|
|
201
|
+
assert.equal(tr.type, "tool_result");
|
|
202
|
+
assert.deepEqual(tr.content, [
|
|
203
|
+
{ type: "text", text: "line 1" },
|
|
204
|
+
{ type: "text", text: "line 2" },
|
|
205
|
+
]);
|
|
206
|
+
} finally {
|
|
207
|
+
cleanup(dir);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("tool_result with is_error:true preserved", async () => {
|
|
212
|
+
const { dir, file } = mkTmpFile([
|
|
213
|
+
{
|
|
214
|
+
type: "user",
|
|
215
|
+
message: {
|
|
216
|
+
role: "user",
|
|
217
|
+
content: [
|
|
218
|
+
{ type: "tool_result", tool_use_id: "toolu_03", content: "oops", is_error: true },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
try {
|
|
224
|
+
const got = await parseTranscript(file);
|
|
225
|
+
assert.equal(got.messages[0].content[0].is_error, true);
|
|
226
|
+
} finally {
|
|
227
|
+
cleanup(dir);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("malformed line in the middle → skipped, surrounding lines kept", async () => {
|
|
232
|
+
const { dir, file } = mkTmpFile([
|
|
233
|
+
JSON.stringify({ type: "user", message: { role: "user", content: "first" } }),
|
|
234
|
+
"{this is not valid json",
|
|
235
|
+
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "second" }] } }),
|
|
236
|
+
]);
|
|
237
|
+
try {
|
|
238
|
+
const got = await parseTranscript(file);
|
|
239
|
+
assert.equal(got.messages.length, 2);
|
|
240
|
+
assert.equal(got.messages[0].content[0].text, "first");
|
|
241
|
+
assert.equal(got.messages[1].content[0].text, "second");
|
|
242
|
+
} finally {
|
|
243
|
+
cleanup(dir);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("user/assistant entry with no message field → skipped", async () => {
|
|
248
|
+
const { dir, file } = mkTmpFile([
|
|
249
|
+
{ type: "user" },
|
|
250
|
+
{ type: "assistant", message: null },
|
|
251
|
+
{ type: "user", message: { role: "user", content: "kept" } },
|
|
252
|
+
]);
|
|
253
|
+
try {
|
|
254
|
+
const got = await parseTranscript(file);
|
|
255
|
+
assert.equal(got.messages.length, 1);
|
|
256
|
+
assert.equal(got.messages[0].content[0].text, "kept");
|
|
257
|
+
} finally {
|
|
258
|
+
cleanup(dir);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("tool_use missing id or name → block dropped", async () => {
|
|
263
|
+
const { dir, file } = mkTmpFile([
|
|
264
|
+
{
|
|
265
|
+
type: "assistant",
|
|
266
|
+
message: {
|
|
267
|
+
role: "assistant",
|
|
268
|
+
content: [
|
|
269
|
+
{ type: "text", text: "ok" },
|
|
270
|
+
{ type: "tool_use", name: "NoId", input: {} },
|
|
271
|
+
{ type: "tool_use", id: "toolu_x" /* no name */ },
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
try {
|
|
277
|
+
const got = await parseTranscript(file);
|
|
278
|
+
assert.equal(got.messages.length, 1);
|
|
279
|
+
// only text block survives
|
|
280
|
+
assert.equal(got.messages[0].content.length, 1);
|
|
281
|
+
assert.equal(got.messages[0].content[0].type, "text");
|
|
282
|
+
} finally {
|
|
283
|
+
cleanup(dir);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("blank / whitespace-only lines are skipped", async () => {
|
|
288
|
+
const { dir, file } = mkTmpFile([
|
|
289
|
+
"",
|
|
290
|
+
" ",
|
|
291
|
+
JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }),
|
|
292
|
+
"",
|
|
293
|
+
]);
|
|
294
|
+
try {
|
|
295
|
+
const got = await parseTranscript(file);
|
|
296
|
+
assert.equal(got.messages.length, 1);
|
|
297
|
+
} finally {
|
|
298
|
+
cleanup(dir);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("message with content array but no recognized blocks → message skipped", async () => {
|
|
303
|
+
const { dir, file } = mkTmpFile([
|
|
304
|
+
{
|
|
305
|
+
type: "assistant",
|
|
306
|
+
message: {
|
|
307
|
+
role: "assistant",
|
|
308
|
+
content: [{ type: "thinking", thinking: "x", signature: "y" }],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
{ type: "user", message: { role: "user", content: "survivor" } },
|
|
312
|
+
]);
|
|
313
|
+
try {
|
|
314
|
+
const got = await parseTranscript(file);
|
|
315
|
+
assert.equal(got.messages.length, 1);
|
|
316
|
+
assert.equal(got.messages[0].content[0].text, "survivor");
|
|
317
|
+
} finally {
|
|
318
|
+
cleanup(dir);
|
|
319
|
+
}
|
|
320
|
+
});
|