@rubytech/create-maxy-code 0.1.66 → 0.1.67

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +251 -94
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +119 -71
  5. package/payload/platform/plugins/docs/references/admin-session.md +16 -10
  6. package/payload/platform/plugins/docs/references/internals.md +2 -0
  7. package/payload/platform/plugins/docs/references/platform.md +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -0
  9. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/http-server.js +54 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
  13. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
  14. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
  15. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +43 -0
  17. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +119 -5
  19. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
  21. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  23. package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
  24. package/payload/server/public/assets/{admin-ymReS1mN.js → admin-t8QqVwJF.js} +1 -1
  25. package/payload/server/public/assets/{data-D6U2e85B.js → data-vHmNQyXt.js} +1 -1
  26. package/payload/server/public/assets/{graph-C274PN27.js → graph-C7QOwigU.js} +1 -1
  27. package/payload/server/public/assets/graph-labels-Dm2RMEFy.js +1 -0
  28. package/payload/server/public/assets/{page-D-OLjort.js → page-B9GFro9g.js} +1 -1
  29. package/payload/server/public/assets/{page-DFFt9_Q0.js → page-BWKfXlHt.js} +1 -1
  30. package/payload/server/public/data.html +3 -3
  31. package/payload/server/public/graph.html +3 -3
  32. package/payload/server/public/index.html +4 -4
  33. package/payload/server/server.js +2 -0
  34. package/payload/server/public/assets/graph-labels-DQFzd1FY.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -129,7 +129,7 @@ Tools are available via the `admin` MCP server.
129
129
  - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
130
130
  - `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
131
131
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
132
- - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 175** — `initialMessage` is now a JSON-stringified envelope conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `operatorMessage`, `assistantReply`, `toolCalls`, `conversationId`, `accountId`, `occurredAt`. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
132
+ - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
133
133
 
134
134
  ## Session identifiers (Task 135)
135
135
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Task 129/131/165/175 regression test for the Stop hook.
2
+ # Task 129/131/165/175/177 regression test for the Stop hook.
3
3
  #
4
4
  # Contract:
5
5
  # - Every log emission goes through `POST /api/admin/log-ingest`. Hook
@@ -11,11 +11,14 @@
11
11
  # `conversationBytes=<n>` and exactly ONE POST to
12
12
  # `/api/admin/claude-sessions`. The body carries the operator
13
13
  # session id as `adminSessionId` and an `initialMessage` whose value
14
- # is a JSON-stringified object with EXACTLY the six keys declared by
15
- # the database-operator agent file: operatorMessage, assistantReply,
16
- # toolCalls, conversationId, accountId, occurredAt. No leading
17
- # instruction prose. No `Conversation id:` prose. No
18
- # `user:`/`assistant:` walk format.
14
+ # is a JSON-stringified object with EXACTLY the four keys declared by
15
+ # the Task 177 envelope: turns, conversationId, accountId, occurredAt.
16
+ # `turns` is the chronologically-ordered list of conversation turns.
17
+ # No top-level operatorMessage. No top-level assistantReply.
18
+ # No leading instruction prose. No `Conversation id:` prose.
19
+ # - The hook emits one `[turn-recorder] envelope ...` log line before
20
+ # `spawn-request`, naming turnsCount / userTurns / assistantTurns /
21
+ # toolCallTurns.
19
22
  # - The hook never writes the legacy `spawn-with-input` / `fired` stderr
20
23
  # lines.
21
24
  #
@@ -119,14 +122,12 @@ for raw in sys.stdin:
119
122
  ' || true
120
123
  }
121
124
 
122
- # Common envelope: a valid Stop-hook stdin shape with a real UUID + a
123
- # transcript file the hook can read. Transcript has one user turn and
124
- # one assistant turn.
125
125
  start_listener
126
+ # Simple two-turn transcript with timestamps. One user, one assistant text.
126
127
  TRANSCRIPT=$(mktemp); TMPFILES+=("$TRANSCRIPT")
127
128
  {
128
- printf '{"type":"user","message":{"role":"user","content":"hello operator"}}\n'
129
- printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello, assistant reply"}]}}\n'
129
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:00.000Z","message":{"role":"user","content":"hello operator"}}\n'
130
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:27:01.000Z","message":{"id":"msg_001","role":"assistant","content":[{"type":"text","text":"hello, assistant reply"}]}}\n'
130
131
  } > "$TRANSCRIPT"
131
132
  ENVELOPE=$(python3 -c '
132
133
  import json, sys
@@ -181,19 +182,33 @@ else
181
182
  fail "case-4 expected trigger-skipped missing-transcript, got: $(ingest_lines)"
182
183
  fi
183
184
 
184
- # --- Case 5: happy path -------------------------------------------------
185
+ # --- Case 5 (Task 177): ordered-turns happy path -----------------------
186
+ # Transcript with four turns: user-A, asst-A-reply, user-B, asst-B-reply.
187
+ # Envelope `turns` array must carry exactly that order, oldest first.
188
+ # Envelope shape: { turns, conversationId, accountId, occurredAt } — no
189
+ # top-level operatorMessage / assistantReply.
190
+ ORDERED_TRANSCRIPT=$(mktemp); TMPFILES+=("$ORDERED_TRANSCRIPT")
191
+ {
192
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:42.258Z","message":{"role":"user","content":"New Real Agent session"}}\n'
193
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:27:45.158Z","message":{"id":"msg_a","role":"assistant","content":[{"type":"text","text":"Session noted. What would you like to work on?"}]}}\n'
194
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:57.314Z","message":{"role":"user","content":"you tell me"}}\n'
195
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:28:00.847Z","message":{"id":"msg_b","role":"assistant","content":[{"type":"text","text":"One question to anchor the next move: what is your name?"}]}}\n'
196
+ } > "$ORDERED_TRANSCRIPT"
197
+ ORDERED_ENVELOPE=$(python3 -c '
198
+ import json, sys
199
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
200
+ ' "$OP_ID" "$ORDERED_TRANSCRIPT")
185
201
  : > "$REQ_LOG"
186
- run_hook "admin" "" "$ENVELOPE"
202
+ run_hook "admin" "" "$ORDERED_ENVELOPE"
187
203
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC stderr=$HOOK_STDERR"
188
204
  [[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty, got: $HOOK_STDERR"
189
205
 
190
- # 5a. exactly ONE `trigger` log-ingest line carrying the operator session id
191
- # and `conversationBytes=<n>`.
206
+ # 5a. exactly ONE `trigger` log-ingest line carrying the operator session id.
192
207
  TRIGGER_COUNT=$(ingest_lines | grep -cE "^trigger sessionId=${OP_ID} turnIndex=0 transcriptBytes=[0-9]+ conversationBytes=[0-9]+$" || true)
193
208
  if [[ "$TRIGGER_COUNT" -eq 1 ]]; then
194
- pass "case-5a exactly one trigger line emitted via log-ingest carrying operator id + conversationBytes"
209
+ pass "case-5a exactly one trigger line via log-ingest"
195
210
  else
196
- fail "case-5a expected exactly 1 trigger line carrying ${OP_ID}, got $TRIGGER_COUNT (lines: $(ingest_lines))"
211
+ fail "case-5a expected exactly 1 trigger line, got $TRIGGER_COUNT (lines: $(ingest_lines))"
197
212
  fi
198
213
 
199
214
  # 5b. exactly ONE POST to /api/admin/claude-sessions
@@ -204,10 +219,10 @@ else
204
219
  fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
205
220
  fi
206
221
 
207
- # 5c. Task 175 `initialMessage` is a JSON-stringified object with
208
- # EXACTLY the six declared top-level keys. No instruction prose.
209
- # `operatorMessage` / `assistantReply` carry the last user / assistant
210
- # text blocks. `toolCalls` is `[]` (no tool_use in this transcript).
222
+ # 5c. Envelope has exactly four keys (turns, conversationId, accountId,
223
+ # occurredAt). `turns` carries 4 entries in record order; each has
224
+ # role/text/ts. No `toolCalls` on turns where there were no tool_use
225
+ # blocks (omitted-when-empty rule).
211
226
  RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
212
227
  BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
213
228
  import sys, json, re
@@ -216,36 +231,43 @@ acct = sys.argv[2]
216
231
  try:
217
232
  outer = json.load(sys.stdin)
218
233
  msg_str = outer.get("initialMessage")
219
- # initialMessage must be a string carrying a JSON-stringified object.
220
234
  if not isinstance(msg_str, str):
221
235
  print("no:initialMessage-not-string:"+repr(type(msg_str).__name__))
222
236
  sys.exit(0)
223
237
  msg = json.loads(msg_str)
224
238
  keys = set(msg.keys())
225
- conds = (
226
- keys == {"operatorMessage", "assistantReply", "toolCalls", "conversationId", "accountId", "occurredAt"},
227
- msg.get("operatorMessage") == "hello operator",
228
- msg.get("assistantReply") == "hello, assistant reply",
229
- msg.get("toolCalls") == [],
230
- msg.get("conversationId") == op,
231
- msg.get("accountId") == acct,
232
- bool(re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$", msg.get("occurredAt") or "")),
233
- "update the graph" not in msg_str,
234
- "user:" not in msg_str.lower() or "user:" not in (msg.get("operatorMessage") or "").lower(),
235
- "Conversation id:" not in msg_str,
236
- outer.get("specialist") == "database-operator",
237
- outer.get("model") == "haiku",
238
- outer.get("adminSessionId") == op,
239
- outer.get("channel") == "browser",
240
- "senderId" not in outer,
241
- "originatingSessionId" not in outer,
242
- )
243
- print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|msg="+repr(msg))
239
+ turns = msg.get("turns") or []
240
+ conds = []
241
+ conds.append(("envelope-keys", keys == {"turns", "conversationId", "accountId", "occurredAt"}))
242
+ conds.append(("turns-len", len(turns) == 4))
243
+ conds.append(("conversationId", msg.get("conversationId") == op))
244
+ conds.append(("accountId", msg.get("accountId") == acct))
245
+ conds.append(("occurredAt-shape", bool(re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$", msg.get("occurredAt") or ""))))
246
+ conds.append(("no-operatorMessage", "operatorMessage" not in keys))
247
+ conds.append(("no-assistantReply", "assistantReply" not in keys))
248
+ if len(turns) >= 4:
249
+ conds.append(("t0-role", turns[0].get("role") == "user"))
250
+ conds.append(("t0-text", turns[0].get("text") == "New Real Agent session"))
251
+ conds.append(("t0-ts", turns[0].get("ts") == "2026-05-19T21:27:42.258Z"))
252
+ conds.append(("t0-no-toolCalls", "toolCalls" not in turns[0]))
253
+ conds.append(("t1-role", turns[1].get("role") == "assistant"))
254
+ conds.append(("t1-text", turns[1].get("text") == "Session noted. What would you like to work on?"))
255
+ conds.append(("t1-ts", turns[1].get("ts") == "2026-05-19T21:27:45.158Z"))
256
+ conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
257
+ conds.append(("t2-role", turns[2].get("role") == "user"))
258
+ conds.append(("t2-text", turns[2].get("text") == "you tell me"))
259
+ conds.append(("t3-role", turns[3].get("role") == "assistant"))
260
+ conds.append(("t3-text", turns[3].get("text") == "One question to anchor the next move: what is your name?"))
261
+ conds.append(("outer-specialist", outer.get("specialist") == "database-operator"))
262
+ conds.append(("outer-model", outer.get("model") == "haiku"))
263
+ conds.append(("outer-adminSessionId", outer.get("adminSessionId") == op))
264
+ failed = [name for name, ok in conds if not ok]
265
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
244
266
  except Exception as e:
245
267
  print("parse-fail:"+str(e))
246
268
  ' "$OP_ID" "$ACCT_ID" 2>/dev/null)
247
269
  if [[ "$BODY_OK" == "yes" ]]; then
248
- pass "case-5c initialMessage is JSON envelope with exactly 6 declared keys"
270
+ pass "case-5c envelope is { turns, conversationId, accountId, occurredAt }; turns is the 4-entry ordered list"
249
271
  else
250
272
  fail "case-5c envelope shape wrong ($BODY_OK): $RECORDER_BODY"
251
273
  fi
@@ -258,51 +280,95 @@ else
258
280
  fail "case-5d expected 1 spawn-request, got $SPAWN_REQ_COUNT (lines: $(ingest_lines))"
259
281
  fi
260
282
 
261
- # 5e. no legacy stderr emissions
283
+ # 5e. Task 177 observability — exactly ONE `[turn-recorder] envelope ...`
284
+ # line emitted before `spawn-request`. The line carries turnsCount,
285
+ # userTurns, assistantTurns, toolCallTurns.
286
+ ENVELOPE_LINE_COUNT=$(ingest_lines | grep -cE "^envelope sessionId=${OP_ID} turnsCount=4 userTurns=2 assistantTurns=2 toolCallTurns=0$" || true)
287
+ if [[ "$ENVELOPE_LINE_COUNT" -eq 1 ]]; then
288
+ pass "case-5e envelope log line emitted with correct counts"
289
+ else
290
+ fail "case-5e expected exactly 1 envelope line w/ turnsCount=4 userTurns=2 assistantTurns=2 toolCallTurns=0, got $ENVELOPE_LINE_COUNT (lines: $(ingest_lines))"
291
+ fi
292
+
293
+ # 5f. envelope line precedes spawn-request line.
294
+ ALL_LINES=$(ingest_lines)
295
+ ENV_POS=$(printf '%s\n' "$ALL_LINES" | grep -nE "^envelope sessionId=${OP_ID}" | head -1 | cut -d: -f1)
296
+ SPAWN_POS=$(printf '%s\n' "$ALL_LINES" | grep -nE "^spawn-request sessionId=${OP_ID}" | head -1 | cut -d: -f1)
297
+ if [[ -n "$ENV_POS" && -n "$SPAWN_POS" && "$ENV_POS" -lt "$SPAWN_POS" ]]; then
298
+ pass "case-5f envelope line precedes spawn-request"
299
+ else
300
+ fail "case-5f envelope line must precede spawn-request (env=$ENV_POS spawn=$SPAWN_POS)"
301
+ fi
302
+
303
+ # 5g. no legacy stderr emissions
262
304
  if echo "$HOOK_STDERR" | grep -qE 'spawn-with-input|spawn-with-stdin|turn-completed-graph-write fired'; then
263
- fail "case-5e legacy stderr line emitted: $HOOK_STDERR"
305
+ fail "case-5g legacy stderr line emitted: $HOOK_STDERR"
264
306
  else
265
- pass "case-5e no legacy stderr emissions"
307
+ pass "case-5g no legacy stderr emissions"
266
308
  fi
267
309
 
268
- # --- Case 6 (Task 175): conversation-empty skip when there's no user/
269
- # assistant text AND no tool_use. The hook emits the trigger line
270
- # first (conversationBytes=0) and then the skip reason.
271
- EMPTY_TRANSCRIPT=$(mktemp); TMPFILES+=("$EMPTY_TRANSCRIPT")
272
- printf '{"type":"system","summary":"noop"}\n' > "$EMPTY_TRANSCRIPT"
273
- EMPTY_ENVELOPE=$(python3 -c '
310
+ # --- Case 6 (Task 177): multi-block assistant collapse -----------------
311
+ # Two assistant JSONL records sharing `message.id`: first is thinking-only,
312
+ # second carries the text. Walker collapses both into ONE `turns` entry
313
+ # whose text is the text-record's text. Thinking records contribute no
314
+ # text and do not establish the turn; `ts` is the first CONTRIBUTING
315
+ # record's ts (here, the text record's ts).
316
+ MULTI_TRANSCRIPT=$(mktemp); TMPFILES+=("$MULTI_TRANSCRIPT")
317
+ {
318
+ printf '{"type":"user","timestamp":"2026-05-19T21:30:00.000Z","message":{"role":"user","content":"think then answer"}}\n'
319
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:30:01.000Z","message":{"id":"msg_multi","role":"assistant","content":[{"type":"thinking","thinking":"considering..."}]}}\n'
320
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:30:02.000Z","message":{"id":"msg_multi","role":"assistant","content":[{"type":"text","text":"answer is 42"}]}}\n'
321
+ } > "$MULTI_TRANSCRIPT"
322
+ MULTI_ENVELOPE=$(python3 -c '
274
323
  import json, sys
275
324
  print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
276
- ' "$OP_ID" "$EMPTY_TRANSCRIPT")
325
+ ' "$OP_ID" "$MULTI_TRANSCRIPT")
277
326
  : > "$REQ_LOG"
278
- run_hook "admin" "" "$EMPTY_ENVELOPE"
279
- [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC"
280
- if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=conversation-empty$"; then
281
- pass "case-6 zero user/assistant/tool records → trigger-skipped reason=conversation-empty emitted"
327
+ run_hook "admin" "" "$MULTI_ENVELOPE"
328
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC stderr=$HOOK_STDERR"
329
+ MULTI_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
330
+ MULTI_OK=$(printf '%s' "$MULTI_BODY" | python3 -c '
331
+ import sys, json
332
+ try:
333
+ outer = json.load(sys.stdin)
334
+ msg = json.loads(outer.get("initialMessage"))
335
+ turns = msg.get("turns") or []
336
+ conds = []
337
+ conds.append(("turns-len-2", len(turns) == 2))
338
+ if len(turns) >= 2:
339
+ conds.append(("t0-user", turns[0].get("role") == "user" and turns[0].get("text") == "think then answer"))
340
+ conds.append(("t1-assistant", turns[1].get("role") == "assistant"))
341
+ conds.append(("t1-text", turns[1].get("text") == "answer is 42"))
342
+ conds.append(("t1-ts-text-record", turns[1].get("ts") == "2026-05-19T21:30:02.000Z"))
343
+ conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
344
+ failed = [name for name, ok in conds if not ok]
345
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
346
+ except Exception as e:
347
+ print("parse-fail:"+str(e))
348
+ ' 2>/dev/null)
349
+ if [[ "$MULTI_OK" == "yes" ]]; then
350
+ pass "case-6 multi-block assistant collapses to one turn entry; ts is the first record's ts"
282
351
  else
283
- fail "case-6 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
284
- fi
285
- # And no recorder-spawn POST is made.
286
- if grep -qE '^/api/admin/claude-sessions ' "$REQ_LOG"; then
287
- fail "case-6 recorder-spawn must NOT be called when conversation-empty"
352
+ fail "case-6 multi-block collapse wrong ($MULTI_OK): $MULTI_BODY"
288
353
  fi
289
354
 
290
- # --- Case 7 (Task 175): tool-call passthrough tool_use + tool_result
291
- # pair surfaces in `toolCalls[]` with `input` and `output` as native
292
- # JSON values, never re-stringified.
355
+ # --- Case 7 (Task 177): tool-call pairing on owning assistant turn -----
356
+ # Assistant tool_use followed by user tool_result. Both attach as ONE
357
+ # `toolCalls` entry on the assistant turn that owned the tool_use.
358
+ # The tool_result-only user record does NOT create a separate user turn.
293
359
  TOOL_TRANSCRIPT=$(mktemp); TMPFILES+=("$TOOL_TRANSCRIPT")
294
360
  python3 - "$TOOL_TRANSCRIPT" <<'PY'
295
361
  import json, sys
296
362
  out = sys.argv[1]
297
363
  recs = [
298
- {"type":"user","message":{"role":"user","content":"please write a node"}},
299
- {"type":"assistant","message":{"role":"assistant","content":[
364
+ {"type":"user","timestamp":"2026-05-19T21:32:00.000Z","message":{"role":"user","content":"please write a node"}},
365
+ {"type":"assistant","timestamp":"2026-05-19T21:32:01.000Z","message":{"id":"msg_tu","role":"assistant","content":[
300
366
  {"type":"tool_use","id":"toolu_001","name":"mcp__memory__memory-write","input":{"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}}
301
367
  ]}},
302
- {"type":"user","message":{"role":"user","content":[
368
+ {"type":"user","timestamp":"2026-05-19T21:32:02.000Z","message":{"role":"user","content":[
303
369
  {"type":"tool_result","tool_use_id":"toolu_001","content":"ok: wrote LocalBusiness"}
304
370
  ]}},
305
- {"type":"assistant","message":{"role":"assistant","content":[
371
+ {"type":"assistant","timestamp":"2026-05-19T21:32:03.000Z","message":{"id":"msg_done","role":"assistant","content":[
306
372
  {"type":"text","text":"done"}
307
373
  ]}},
308
374
  ]
@@ -322,29 +388,117 @@ import sys, json
322
388
  try:
323
389
  outer = json.load(sys.stdin)
324
390
  msg = json.loads(outer.get("initialMessage"))
325
- calls = msg.get("toolCalls") or []
326
- if len(calls) != 1:
327
- print("no:expected-1-call:"+repr(calls)); sys.exit(0)
328
- c = calls[0]
329
- conds = (
330
- c.get("tool") == "mcp__memory__memory-write",
331
- c.get("input") == {"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}},
332
- c.get("output") == "ok: wrote LocalBusiness",
333
- msg.get("operatorMessage") == "please write a node",
334
- msg.get("assistantReply") == "done",
335
- )
336
- print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|call="+repr(c))
391
+ turns = msg.get("turns") or []
392
+ conds = []
393
+ # Expected 3 turns: user "please write a node", assistant tool_use (text=""),
394
+ # assistant "done". The user tool_result record carries no separate user turn.
395
+ conds.append(("turns-len-3", len(turns) == 3))
396
+ if len(turns) >= 3:
397
+ conds.append(("t0", turns[0].get("role") == "user" and turns[0].get("text") == "please write a node"))
398
+ conds.append(("t1-role", turns[1].get("role") == "assistant"))
399
+ conds.append(("t1-text-empty", turns[1].get("text") == ""))
400
+ conds.append(("t1-toolCalls-len", len(turns[1].get("toolCalls") or []) == 1))
401
+ tc = (turns[1].get("toolCalls") or [{}])[0]
402
+ conds.append(("tc-tool", tc.get("tool") == "mcp__memory__memory-write"))
403
+ conds.append(("tc-input", tc.get("input") == {"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}))
404
+ conds.append(("tc-output", tc.get("output") == "ok: wrote LocalBusiness"))
405
+ conds.append(("t2", turns[2].get("role") == "assistant" and turns[2].get("text") == "done"))
406
+ conds.append(("t2-no-toolCalls", "toolCalls" not in turns[2]))
407
+ failed = [name for name, ok in conds if not ok]
408
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
337
409
  except Exception as e:
338
410
  print("parse-fail:"+str(e))
339
411
  ' 2>/dev/null)
340
412
  if [[ "$TOOL_OK" == "yes" ]]; then
341
- pass "case-7 toolCalls passthrough: input/output are JSON values, not strings"
413
+ pass "case-7 tool_use + tool_result land as one toolCalls entry on owning assistant turn; no extra user turn"
342
414
  else
343
- fail "case-7 toolCalls shape wrong ($TOOL_OK): $TOOL_BODY"
415
+ fail "case-7 tool pairing wrong ($TOOL_OK): $TOOL_BODY"
344
416
  fi
345
417
 
346
- # --- Case 8 (Task 175): escaping — quotes, backslashes, newlines, non-ASCII
347
- # all round-trip cleanly through the JSON envelope.
418
+ # Envelope log line for case-7: turnsCount=3, userTurns=1, assistantTurns=2, toolCallTurns=1
419
+ ENV_LINE_7=$(ingest_lines | grep -cE "^envelope sessionId=${OP_ID} turnsCount=3 userTurns=1 assistantTurns=2 toolCallTurns=1$" || true)
420
+ if [[ "$ENV_LINE_7" -eq 1 ]]; then
421
+ pass "case-7 envelope log line counts tool-call turn correctly"
422
+ else
423
+ fail "case-7 expected envelope line w/ turnsCount=3 userTurns=1 assistantTurns=2 toolCallTurns=1, got $ENV_LINE_7 (lines: $(ingest_lines))"
424
+ fi
425
+
426
+ # --- Case 8 (Task 177): long-conversation 100 turns ------------------
427
+ # Transcript with 100 turns (50 user + 50 assistant, interleaved) →
428
+ # turns.length === 100. No drop, no truncation, no env-knob override.
429
+ LONG_TRANSCRIPT=$(mktemp); TMPFILES+=("$LONG_TRANSCRIPT")
430
+ python3 - "$LONG_TRANSCRIPT" <<'PY'
431
+ import json, sys
432
+ out = sys.argv[1]
433
+ with open(out,"w") as f:
434
+ for i in range(50):
435
+ ts_u = f"2026-05-19T21:{(i*2)//60:02d}:{(i*2)%60:02d}.000Z"
436
+ ts_a = f"2026-05-19T21:{(i*2+1)//60:02d}:{(i*2+1)%60:02d}.000Z"
437
+ f.write(json.dumps({"type":"user","timestamp":ts_u,"message":{"role":"user","content":f"user msg {i}"}})+"\n")
438
+ f.write(json.dumps({"type":"assistant","timestamp":ts_a,"message":{"id":f"msg_{i}","role":"assistant","content":[{"type":"text","text":f"asst reply {i}"}]}})+"\n")
439
+ PY
440
+ LONG_ENVELOPE=$(python3 -c '
441
+ import json, sys
442
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
443
+ ' "$OP_ID" "$LONG_TRANSCRIPT")
444
+ : > "$REQ_LOG"
445
+ run_hook "admin" "" "$LONG_ENVELOPE"
446
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-8 rc=$HOOK_RC stderr=$HOOK_STDERR"
447
+ LONG_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
448
+ LONG_OK=$(printf '%s' "$LONG_BODY" | python3 -c '
449
+ import sys, json
450
+ try:
451
+ outer = json.load(sys.stdin)
452
+ msg = json.loads(outer.get("initialMessage"))
453
+ turns = msg.get("turns") or []
454
+ conds = []
455
+ conds.append(("turns-len-100", len(turns) == 100))
456
+ if len(turns) == 100:
457
+ conds.append(("first-user-0", turns[0].get("role") == "user" and turns[0].get("text") == "user msg 0"))
458
+ conds.append(("last-asst-49", turns[99].get("role") == "assistant" and turns[99].get("text") == "asst reply 49"))
459
+ # ts strictly non-decreasing
460
+ tss = [t.get("ts","") for t in turns]
461
+ conds.append(("ts-ordered", tss == sorted(tss)))
462
+ failed = [name for name, ok in conds if not ok]
463
+ print("yes" if not failed else "no:"+repr(failed)+"|len="+str(len(turns)))
464
+ except Exception as e:
465
+ print("parse-fail:"+str(e))
466
+ ' 2>/dev/null)
467
+ if [[ "$LONG_OK" == "yes" ]]; then
468
+ pass "case-8 100-turn transcript → turns.length === 100, oldest first, no truncation"
469
+ else
470
+ fail "case-8 long-conversation wrong ($LONG_OK)"
471
+ fi
472
+
473
+ # --- Case 9 (Task 177): empty-skip ------------------------------------
474
+ # Transcript with no text content and no tool_use → turns array is empty
475
+ # → trigger-skipped reason=conversation-empty, no spawn POST.
476
+ EMPTY_TRANSCRIPT=$(mktemp); TMPFILES+=("$EMPTY_TRANSCRIPT")
477
+ {
478
+ printf '{"type":"system","summary":"noop"}\n'
479
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:40:00.000Z","message":{"id":"msg_t","role":"assistant","content":[{"type":"thinking","thinking":"silent"}]}}\n'
480
+ } > "$EMPTY_TRANSCRIPT"
481
+ EMPTY_ENVELOPE=$(python3 -c '
482
+ import json, sys
483
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
484
+ ' "$OP_ID" "$EMPTY_TRANSCRIPT")
485
+ : > "$REQ_LOG"
486
+ run_hook "admin" "" "$EMPTY_ENVELOPE"
487
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-9 rc=$HOOK_RC"
488
+ if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=conversation-empty$"; then
489
+ pass "case-9 thinking-only transcript → trigger-skipped reason=conversation-empty"
490
+ else
491
+ fail "case-9 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
492
+ fi
493
+ if grep -qE '^/api/admin/claude-sessions ' "$REQ_LOG"; then
494
+ fail "case-9 recorder-spawn must NOT be called when conversation-empty"
495
+ else
496
+ pass "case-9 no recorder-spawn POST"
497
+ fi
498
+
499
+ # --- Case 10 (Task 177): escaping round-trip --------------------------
500
+ # Quotes, backslashes, newlines, non-ASCII all round-trip cleanly through
501
+ # the JSON envelope's `turns[*].text` field.
348
502
  ESC_TRANSCRIPT=$(mktemp); TMPFILES+=("$ESC_TRANSCRIPT")
349
503
  python3 - "$ESC_TRANSCRIPT" <<'PY'
350
504
  import json, sys
@@ -352,8 +506,8 @@ out = sys.argv[1]
352
506
  op_msg = 'hello "world"\\nline2\nlast — café 🦊'
353
507
  asst_msg = 'reply with backslash \\\\ and newline\nhere'
354
508
  recs = [
355
- {"type":"user","message":{"role":"user","content":op_msg}},
356
- {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":asst_msg}]}},
509
+ {"type":"user","timestamp":"2026-05-19T21:50:00.000Z","message":{"role":"user","content":op_msg}},
510
+ {"type":"assistant","timestamp":"2026-05-19T21:50:01.000Z","message":{"id":"msg_esc","role":"assistant","content":[{"type":"text","text":asst_msg}]}},
357
511
  ]
358
512
  with open(out,"w") as f:
359
513
  for r in recs: f.write(json.dumps(r)+"\n")
@@ -364,7 +518,7 @@ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
364
518
  ' "$OP_ID" "$ESC_TRANSCRIPT")
365
519
  : > "$REQ_LOG"
366
520
  run_hook "admin" "" "$ESC_ENVELOPE"
367
- [[ "$HOOK_RC" -eq 0 ]] || fail "case-8 rc=$HOOK_RC stderr=$HOOK_STDERR"
521
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-10 rc=$HOOK_RC stderr=$HOOK_STDERR"
368
522
  ESC_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
369
523
  ESC_OK=$(printf '%s' "$ESC_BODY" | python3 -c '
370
524
  import sys, json
@@ -373,18 +527,21 @@ EXPECTED_ASST = "reply with backslash \\\\ and newline\nhere"
373
527
  try:
374
528
  outer = json.load(sys.stdin)
375
529
  msg = json.loads(outer.get("initialMessage"))
376
- conds = (
377
- msg.get("operatorMessage") == EXPECTED_OP,
378
- msg.get("assistantReply") == EXPECTED_ASST,
379
- )
380
- print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|op="+repr(msg.get("operatorMessage"))+"|asst="+repr(msg.get("assistantReply")))
530
+ turns = msg.get("turns") or []
531
+ conds = []
532
+ conds.append(("len-2", len(turns) == 2))
533
+ if len(turns) == 2:
534
+ conds.append(("op-text", turns[0].get("text") == EXPECTED_OP))
535
+ conds.append(("asst-text", turns[1].get("text") == EXPECTED_ASST))
536
+ failed = [name for name, ok in conds if not ok]
537
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
381
538
  except Exception as e:
382
539
  print("parse-fail:"+str(e))
383
540
  ' 2>/dev/null)
384
541
  if [[ "$ESC_OK" == "yes" ]]; then
385
- pass "case-8 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly"
542
+ pass "case-10 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly through turns[*].text"
386
543
  else
387
- fail "case-8 escaping wrong ($ESC_OK): $ESC_BODY"
544
+ fail "case-10 escaping wrong ($ESC_OK): $ESC_BODY"
388
545
  fi
389
546
 
390
547
  # --- Summary ------------------------------------------------------------