@tekyzinc/gsd-t 3.10.13 → 3.10.14

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.10.14] - 2026-04-15
6
+
7
+ ### Fixed — transcript parser orphaned tool_use blocks cause count_tokens 400
8
+
9
+ **Background**: With `ANTHROPIC_API_KEY` now set (v3.10.12-13 fix), the context meter hook passed the key check but the `count_tokens` API returned HTTP 400: `"tool_use ids were found without tool_result blocks immediately after"`. The transcript parser (`scripts/context-meter/transcript-parser.js`) faithfully reconstructed the JSONL transcript but didn't enforce the API's strict adjacency constraint: every assistant `tool_use` must be immediately followed by a user `tool_result` with matching ids. Mid-session compaction and summarization can orphan these blocks.
10
+
11
+ ### Changed
12
+ - **`scripts/context-meter/transcript-parser.js`** — added `sanitizeToolPairs()` post-processing pass after message reconstruction. Walks the message list enforcing adjacency: assistant `tool_use` blocks are kept only if the immediately following user message contains a `tool_result` with a matching id, and vice versa. Messages that become empty after stripping are dropped. This is a structural fix — any transcript shape (compacted, summarized, interrupted) now produces a valid `count_tokens` payload.
13
+ - **`scripts/context-meter/transcript-parser.test.js`** — updated 2 existing tests that created orphaned `tool_result` messages (now include matching `tool_use` predecessors). Added 1 new test: `orphaned tool_use without matching tool_result is stripped`. 1229/1229 tests pass.
14
+
15
+ ### Verification
16
+ - Real transcript (626→649 messages, 473KB payload) now returns HTTP 200 with `input_tokens: 153597` (was 400 before fix)
17
+ - Context meter state file flipped from `lastError: api_error` to `lastError: null`, `inputTokens: 158543`, `pct: 79.3%`, `threshold: warn`
18
+ - First successful real-time context measurement since M34 was built
19
+
5
20
  ## [3.10.13] - 2026-04-15
6
21
 
7
22
  ### Fixed — P0 v3.10.12 propagation gap (same regression, downstream projects)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.10.13",
3
+ "version": "3.10.14",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 61 slash commands with unattended supervisor relay, headless CI/CD mode, graph-powered code analysis, real-time agent dashboard, execution intelligence, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -152,7 +152,69 @@ async function parseTranscript(transcriptPath) {
152
152
  return null;
153
153
  }
154
154
 
155
- return { system, messages };
155
+ return { system, messages: sanitizeToolPairs(messages) };
156
+ }
157
+
158
+ /**
159
+ * Enforce the count_tokens adjacency constraint: every assistant tool_use
160
+ * must be immediately followed by a user message whose tool_result ids match
161
+ * ALL tool_use ids from the preceding assistant message. Walk the message
162
+ * list and strip tool_use / tool_result blocks from any pair that violates
163
+ * this rule. Drop messages that become empty after stripping.
164
+ */
165
+ function sanitizeToolPairs(messages) {
166
+ const out = [];
167
+ for (let i = 0; i < messages.length; i++) {
168
+ const m = messages[i];
169
+ if (!Array.isArray(m.content)) { out.push(m); continue; }
170
+
171
+ if (m.role === "assistant") {
172
+ const toolUseIds = new Set();
173
+ for (const b of m.content) {
174
+ if (b.type === "tool_use" && typeof b.id === "string") toolUseIds.add(b.id);
175
+ }
176
+
177
+ if (toolUseIds.size === 0) { out.push(m); continue; }
178
+
179
+ const next = messages[i + 1];
180
+ const nextResultIds = new Set();
181
+ if (next && next.role === "user" && Array.isArray(next.content)) {
182
+ for (const b of next.content) {
183
+ if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
184
+ nextResultIds.add(b.tool_use_id);
185
+ }
186
+ }
187
+ }
188
+
189
+ const validIds = new Set([...toolUseIds].filter((id) => nextResultIds.has(id)));
190
+ const filtered = m.content.filter((b) => {
191
+ if (b.type === "tool_use") return validIds.has(b.id);
192
+ return true;
193
+ });
194
+ if (filtered.length > 0) out.push({ role: m.role, content: filtered });
195
+ continue;
196
+ }
197
+
198
+ if (m.role === "user") {
199
+ const prev = out[out.length - 1];
200
+ const prevUseIds = new Set();
201
+ if (prev && prev.role === "assistant" && Array.isArray(prev.content)) {
202
+ for (const b of prev.content) {
203
+ if (b.type === "tool_use" && typeof b.id === "string") prevUseIds.add(b.id);
204
+ }
205
+ }
206
+
207
+ const filtered = m.content.filter((b) => {
208
+ if (b.type === "tool_result") return prevUseIds.has(b.tool_use_id);
209
+ return true;
210
+ });
211
+ if (filtered.length > 0) out.push({ role: m.role, content: filtered });
212
+ continue;
213
+ }
214
+
215
+ out.push(m);
216
+ }
217
+ return out;
156
218
  }
157
219
 
158
220
  /**
@@ -176,6 +176,15 @@ test("tool_use / tool_result pairing by tool_use_id preserved in order", async (
176
176
 
177
177
  test("tool_result with array content (text blocks) is normalized", async () => {
178
178
  const { dir, file } = mkTmpFile([
179
+ {
180
+ type: "assistant",
181
+ message: {
182
+ role: "assistant",
183
+ content: [
184
+ { type: "tool_use", id: "toolu_02", name: "Read", input: { file_path: "/tmp/x" } },
185
+ ],
186
+ },
187
+ },
179
188
  {
180
189
  type: "user",
181
190
  message: {
@@ -196,8 +205,8 @@ test("tool_result with array content (text blocks) is normalized", async () => {
196
205
  ]);
197
206
  try {
198
207
  const got = await parseTranscript(file);
199
- assert.equal(got.messages.length, 1);
200
- const tr = got.messages[0].content[0];
208
+ const userMsg = got.messages.find(m => m.role === "user");
209
+ const tr = userMsg.content[0];
201
210
  assert.equal(tr.type, "tool_result");
202
211
  assert.deepEqual(tr.content, [
203
212
  { type: "text", text: "line 1" },
@@ -210,6 +219,15 @@ test("tool_result with array content (text blocks) is normalized", async () => {
210
219
 
211
220
  test("tool_result with is_error:true preserved", async () => {
212
221
  const { dir, file } = mkTmpFile([
222
+ {
223
+ type: "assistant",
224
+ message: {
225
+ role: "assistant",
226
+ content: [
227
+ { type: "tool_use", id: "toolu_03", name: "Bash", input: { command: "false" } },
228
+ ],
229
+ },
230
+ },
213
231
  {
214
232
  type: "user",
215
233
  message: {
@@ -222,7 +240,8 @@ test("tool_result with is_error:true preserved", async () => {
222
240
  ]);
223
241
  try {
224
242
  const got = await parseTranscript(file);
225
- assert.equal(got.messages[0].content[0].is_error, true);
243
+ const userMsg = got.messages.find(m => m.role === "user");
244
+ assert.equal(userMsg.content[0].is_error, true);
226
245
  } finally {
227
246
  cleanup(dir);
228
247
  }
@@ -318,3 +337,34 @@ test("message with content array but no recognized blocks → message skipped",
318
337
  cleanup(dir);
319
338
  }
320
339
  });
340
+
341
+ test("orphaned tool_use without matching tool_result is stripped", async () => {
342
+ const { dir, file } = mkTmpFile([
343
+ { type: "user", message: { role: "user", content: "hello" } },
344
+ {
345
+ type: "assistant",
346
+ message: {
347
+ role: "assistant",
348
+ content: [
349
+ { type: "text", text: "I will read a file" },
350
+ { type: "tool_use", id: "toolu_orphan", name: "Read", input: { file_path: "/tmp/x" } },
351
+ ],
352
+ },
353
+ },
354
+ { type: "user", message: { role: "user", content: "thanks" } },
355
+ ]);
356
+ try {
357
+ const got = await parseTranscript(file);
358
+ const assistantMsg = got.messages.find(
359
+ (m) => m.role === "assistant" && m.content.some((b) => b.type === "text")
360
+ );
361
+ assert.ok(assistantMsg, "assistant message preserved");
362
+ assert.ok(
363
+ !assistantMsg.content.some((b) => b.type === "tool_use"),
364
+ "orphaned tool_use stripped"
365
+ );
366
+ assert.equal(assistantMsg.content[0].text, "I will read a file");
367
+ } finally {
368
+ cleanup(dir);
369
+ }
370
+ });