@rubytech/create-maxy-code 0.1.65 → 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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +317 -81
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +183 -49
- package/payload/platform/plugins/docs/references/admin-session.md +23 -4
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +54 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +43 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +119 -5
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
- package/payload/server/public/assets/{admin-ymReS1mN.js → admin-t8QqVwJF.js} +1 -1
- package/payload/server/public/assets/{data-D6U2e85B.js → data-vHmNQyXt.js} +1 -1
- package/payload/server/public/assets/{graph-C274PN27.js → graph-C7QOwigU.js} +1 -1
- package/payload/server/public/assets/graph-labels-Dm2RMEFy.js +1 -0
- package/payload/server/public/assets/{page-D-OLjort.js → page-B9GFro9g.js} +1 -1
- package/payload/server/public/assets/{page-DFFt9_Q0.js → page-BWKfXlHt.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +2 -0
- package/payload/server/public/assets/graph-labels-DQFzd1FY.js +0 -1
package/package.json
CHANGED
|
@@ -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` + `
|
|
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,18 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Task 129/131 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
|
|
6
6
|
# stderr is silent on every path.
|
|
7
7
|
# - Every gated-off path emits one `trigger-skipped` line carrying a
|
|
8
8
|
# distinct `reason=` (role-not-admin | is-recorder | empty-stdin |
|
|
9
|
-
# missing-transcript |
|
|
9
|
+
# missing-transcript | conversation-empty).
|
|
10
10
|
# - Happy path emits exactly ONE `trigger` line carrying
|
|
11
|
-
# `
|
|
12
|
-
#
|
|
13
|
-
# session id as `
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# `conversationBytes=<n>` and exactly ONE POST to
|
|
12
|
+
# `/api/admin/claude-sessions`. The body carries the operator
|
|
13
|
+
# session id as `adminSessionId` and an `initialMessage` whose value
|
|
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.
|
|
16
22
|
# - The hook never writes the legacy `spawn-with-input` / `fired` stderr
|
|
17
23
|
# lines.
|
|
18
24
|
#
|
|
@@ -30,6 +36,7 @@ if [[ ! -x "$HOOK" ]]; then
|
|
|
30
36
|
fi
|
|
31
37
|
|
|
32
38
|
OP_ID='aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'
|
|
39
|
+
ACCT_ID='acct-test-0001'
|
|
33
40
|
|
|
34
41
|
TMPFILES=()
|
|
35
42
|
LISTENER_PIDS=()
|
|
@@ -92,6 +99,7 @@ run_hook() {
|
|
|
92
99
|
MAXY_SESSION_ROLE="$role" \
|
|
93
100
|
MAXY_SPECIALIST="$specialist" \
|
|
94
101
|
MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
|
|
102
|
+
ACCOUNT_ID="$ACCT_ID" \
|
|
95
103
|
bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
|
|
96
104
|
HOOK_RC=$?
|
|
97
105
|
HOOK_STDERR=$(cat "$stderr_file")
|
|
@@ -114,15 +122,12 @@ for raw in sys.stdin:
|
|
|
114
122
|
' || true
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
# Common envelope: a valid Stop-hook stdin shape with a real UUID + a
|
|
118
|
-
# transcript file the hook can read. Transcript has one user turn and
|
|
119
|
-
# one assistant turn — Task 131 picks the most-recent assistant text as
|
|
120
|
-
# the last-turn payload.
|
|
121
125
|
start_listener
|
|
126
|
+
# Simple two-turn transcript with timestamps. One user, one assistant text.
|
|
122
127
|
TRANSCRIPT=$(mktemp); TMPFILES+=("$TRANSCRIPT")
|
|
123
128
|
{
|
|
124
|
-
printf '{"type":"user","message":{"role":"user","content":"hello operator"}}\n'
|
|
125
|
-
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'
|
|
126
131
|
} > "$TRANSCRIPT"
|
|
127
132
|
ENVELOPE=$(python3 -c '
|
|
128
133
|
import json, sys
|
|
@@ -177,20 +182,33 @@ else
|
|
|
177
182
|
fail "case-4 expected trigger-skipped missing-transcript, got: $(ingest_lines)"
|
|
178
183
|
fi
|
|
179
184
|
|
|
180
|
-
# --- 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")
|
|
181
201
|
: > "$REQ_LOG"
|
|
182
|
-
run_hook "admin" "" "$
|
|
202
|
+
run_hook "admin" "" "$ORDERED_ENVELOPE"
|
|
183
203
|
[[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC stderr=$HOOK_STDERR"
|
|
184
204
|
[[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty, got: $HOOK_STDERR"
|
|
185
205
|
|
|
186
|
-
# 5a. exactly ONE `trigger` log-ingest line carrying the operator session id
|
|
187
|
-
# and `conversationBytes=<n>` (Task 165 — full transcript walker
|
|
188
|
-
# replaces last-turn-only extractor; field is renamed for clarity).
|
|
206
|
+
# 5a. exactly ONE `trigger` log-ingest line carrying the operator session id.
|
|
189
207
|
TRIGGER_COUNT=$(ingest_lines | grep -cE "^trigger sessionId=${OP_ID} turnIndex=0 transcriptBytes=[0-9]+ conversationBytes=[0-9]+$" || true)
|
|
190
208
|
if [[ "$TRIGGER_COUNT" -eq 1 ]]; then
|
|
191
|
-
pass "case-5a exactly one trigger line
|
|
209
|
+
pass "case-5a exactly one trigger line via log-ingest"
|
|
192
210
|
else
|
|
193
|
-
fail "case-5a expected exactly 1 trigger line
|
|
211
|
+
fail "case-5a expected exactly 1 trigger line, got $TRIGGER_COUNT (lines: $(ingest_lines))"
|
|
194
212
|
fi
|
|
195
213
|
|
|
196
214
|
# 5b. exactly ONE POST to /api/admin/claude-sessions
|
|
@@ -201,40 +219,57 @@ else
|
|
|
201
219
|
fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
|
|
202
220
|
fi
|
|
203
221
|
|
|
204
|
-
# 5c.
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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).
|
|
208
226
|
RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
209
227
|
BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
|
|
210
|
-
import sys, json
|
|
228
|
+
import sys, json, re
|
|
211
229
|
op = sys.argv[1]
|
|
212
|
-
|
|
213
|
-
# Task 165 — initialMessage carries the full transcript layout
|
|
214
|
-
# "user: <text>\nassistant: <text>\n…", not just the last assistant text.
|
|
215
|
-
EXPECTED_CONVERSATION = "user: hello operator\nassistant: hello, assistant reply"
|
|
230
|
+
acct = sys.argv[2]
|
|
216
231
|
try:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
)
|
|
230
|
-
|
|
232
|
+
outer = json.load(sys.stdin)
|
|
233
|
+
msg_str = outer.get("initialMessage")
|
|
234
|
+
if not isinstance(msg_str, str):
|
|
235
|
+
print("no:initialMessage-not-string:"+repr(type(msg_str).__name__))
|
|
236
|
+
sys.exit(0)
|
|
237
|
+
msg = json.loads(msg_str)
|
|
238
|
+
keys = set(msg.keys())
|
|
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))
|
|
231
266
|
except Exception as e:
|
|
232
267
|
print("parse-fail:"+str(e))
|
|
233
|
-
' "$OP_ID" 2>/dev/null)
|
|
268
|
+
' "$OP_ID" "$ACCT_ID" 2>/dev/null)
|
|
234
269
|
if [[ "$BODY_OK" == "yes" ]]; then
|
|
235
|
-
pass "case-5c
|
|
270
|
+
pass "case-5c envelope is { turns, conversationId, accountId, occurredAt }; turns is the 4-entry ordered list"
|
|
236
271
|
else
|
|
237
|
-
fail "case-5c
|
|
272
|
+
fail "case-5c envelope shape wrong ($BODY_OK): $RECORDER_BODY"
|
|
238
273
|
fi
|
|
239
274
|
|
|
240
275
|
# 5d. exactly ONE `spawn-request` log-ingest line
|
|
@@ -245,67 +280,268 @@ else
|
|
|
245
280
|
fail "case-5d expected 1 spawn-request, got $SPAWN_REQ_COUNT (lines: $(ingest_lines))"
|
|
246
281
|
fi
|
|
247
282
|
|
|
248
|
-
# 5e.
|
|
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
|
|
249
304
|
if echo "$HOOK_STDERR" | grep -qE 'spawn-with-input|spawn-with-stdin|turn-completed-graph-write fired'; then
|
|
250
|
-
fail "case-
|
|
305
|
+
fail "case-5g legacy stderr line emitted: $HOOK_STDERR"
|
|
306
|
+
else
|
|
307
|
+
pass "case-5g no legacy stderr emissions"
|
|
308
|
+
fi
|
|
309
|
+
|
|
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 '
|
|
323
|
+
import json, sys
|
|
324
|
+
print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
|
|
325
|
+
' "$OP_ID" "$MULTI_TRANSCRIPT")
|
|
326
|
+
: > "$REQ_LOG"
|
|
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"
|
|
351
|
+
else
|
|
352
|
+
fail "case-6 multi-block collapse wrong ($MULTI_OK): $MULTI_BODY"
|
|
353
|
+
fi
|
|
354
|
+
|
|
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.
|
|
359
|
+
TOOL_TRANSCRIPT=$(mktemp); TMPFILES+=("$TOOL_TRANSCRIPT")
|
|
360
|
+
python3 - "$TOOL_TRANSCRIPT" <<'PY'
|
|
361
|
+
import json, sys
|
|
362
|
+
out = sys.argv[1]
|
|
363
|
+
recs = [
|
|
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":[
|
|
366
|
+
{"type":"tool_use","id":"toolu_001","name":"mcp__memory__memory-write","input":{"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}}
|
|
367
|
+
]}},
|
|
368
|
+
{"type":"user","timestamp":"2026-05-19T21:32:02.000Z","message":{"role":"user","content":[
|
|
369
|
+
{"type":"tool_result","tool_use_id":"toolu_001","content":"ok: wrote LocalBusiness"}
|
|
370
|
+
]}},
|
|
371
|
+
{"type":"assistant","timestamp":"2026-05-19T21:32:03.000Z","message":{"id":"msg_done","role":"assistant","content":[
|
|
372
|
+
{"type":"text","text":"done"}
|
|
373
|
+
]}},
|
|
374
|
+
]
|
|
375
|
+
with open(out,"w") as f:
|
|
376
|
+
for r in recs: f.write(json.dumps(r)+"\n")
|
|
377
|
+
PY
|
|
378
|
+
TOOL_ENVELOPE=$(python3 -c '
|
|
379
|
+
import json, sys
|
|
380
|
+
print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
|
|
381
|
+
' "$OP_ID" "$TOOL_TRANSCRIPT")
|
|
382
|
+
: > "$REQ_LOG"
|
|
383
|
+
run_hook "admin" "" "$TOOL_ENVELOPE"
|
|
384
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-7 rc=$HOOK_RC stderr=$HOOK_STDERR"
|
|
385
|
+
TOOL_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
386
|
+
TOOL_OK=$(printf '%s' "$TOOL_BODY" | python3 -c '
|
|
387
|
+
import sys, json
|
|
388
|
+
try:
|
|
389
|
+
outer = json.load(sys.stdin)
|
|
390
|
+
msg = json.loads(outer.get("initialMessage"))
|
|
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))
|
|
409
|
+
except Exception as e:
|
|
410
|
+
print("parse-fail:"+str(e))
|
|
411
|
+
' 2>/dev/null)
|
|
412
|
+
if [[ "$TOOL_OK" == "yes" ]]; then
|
|
413
|
+
pass "case-7 tool_use + tool_result land as one toolCalls entry on owning assistant turn; no extra user turn"
|
|
251
414
|
else
|
|
252
|
-
|
|
415
|
+
fail "case-7 tool pairing wrong ($TOOL_OK): $TOOL_BODY"
|
|
253
416
|
fi
|
|
254
417
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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.
|
|
259
476
|
EMPTY_TRANSCRIPT=$(mktemp); TMPFILES+=("$EMPTY_TRANSCRIPT")
|
|
260
|
-
|
|
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"
|
|
261
481
|
EMPTY_ENVELOPE=$(python3 -c '
|
|
262
482
|
import json, sys
|
|
263
483
|
print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
|
|
264
484
|
' "$OP_ID" "$EMPTY_TRANSCRIPT")
|
|
265
485
|
: > "$REQ_LOG"
|
|
266
486
|
run_hook "admin" "" "$EMPTY_ENVELOPE"
|
|
267
|
-
[[ "$HOOK_RC" -eq 0 ]] || fail "case-
|
|
487
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-9 rc=$HOOK_RC"
|
|
268
488
|
if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=conversation-empty$"; then
|
|
269
|
-
pass "case-
|
|
489
|
+
pass "case-9 thinking-only transcript → trigger-skipped reason=conversation-empty"
|
|
270
490
|
else
|
|
271
|
-
fail "case-
|
|
491
|
+
fail "case-9 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
|
|
272
492
|
fi
|
|
273
|
-
# And no recorder-spawn POST is made.
|
|
274
493
|
if grep -qE '^/api/admin/claude-sessions ' "$REQ_LOG"; then
|
|
275
|
-
fail "case-
|
|
494
|
+
fail "case-9 recorder-spawn must NOT be called when conversation-empty"
|
|
495
|
+
else
|
|
496
|
+
pass "case-9 no recorder-spawn POST"
|
|
276
497
|
fi
|
|
277
498
|
|
|
278
|
-
# --- Case
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
502
|
+
ESC_TRANSCRIPT=$(mktemp); TMPFILES+=("$ESC_TRANSCRIPT")
|
|
503
|
+
python3 - "$ESC_TRANSCRIPT" <<'PY'
|
|
504
|
+
import json, sys
|
|
505
|
+
out = sys.argv[1]
|
|
506
|
+
op_msg = 'hello "world"\\nline2\nlast — café 🦊'
|
|
507
|
+
asst_msg = 'reply with backslash \\\\ and newline\nhere'
|
|
508
|
+
recs = [
|
|
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}]}},
|
|
511
|
+
]
|
|
512
|
+
with open(out,"w") as f:
|
|
513
|
+
for r in recs: f.write(json.dumps(r)+"\n")
|
|
514
|
+
PY
|
|
515
|
+
ESC_ENVELOPE=$(python3 -c '
|
|
287
516
|
import json, sys
|
|
288
517
|
print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
|
|
289
|
-
' "$OP_ID" "$
|
|
518
|
+
' "$OP_ID" "$ESC_TRANSCRIPT")
|
|
290
519
|
: > "$REQ_LOG"
|
|
291
|
-
run_hook "admin" "" "$
|
|
292
|
-
[[ "$HOOK_RC" -eq 0 ]] || fail "case-
|
|
293
|
-
|
|
294
|
-
|
|
520
|
+
run_hook "admin" "" "$ESC_ENVELOPE"
|
|
521
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-10 rc=$HOOK_RC stderr=$HOOK_STDERR"
|
|
522
|
+
ESC_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
523
|
+
ESC_OK=$(printf '%s' "$ESC_BODY" | python3 -c '
|
|
295
524
|
import sys, json
|
|
296
|
-
|
|
297
|
-
|
|
525
|
+
EXPECTED_OP = "hello \"world\"\\nline2\nlast — café \U0001F98A"
|
|
526
|
+
EXPECTED_ASST = "reply with backslash \\\\ and newline\nhere"
|
|
298
527
|
try:
|
|
299
|
-
|
|
300
|
-
msg =
|
|
301
|
-
|
|
528
|
+
outer = json.load(sys.stdin)
|
|
529
|
+
msg = json.loads(outer.get("initialMessage"))
|
|
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))
|
|
302
538
|
except Exception as e:
|
|
303
539
|
print("parse-fail:"+str(e))
|
|
304
540
|
' 2>/dev/null)
|
|
305
|
-
if [[ "$
|
|
306
|
-
pass "case-
|
|
541
|
+
if [[ "$ESC_OK" == "yes" ]]; then
|
|
542
|
+
pass "case-10 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly through turns[*].text"
|
|
307
543
|
else
|
|
308
|
-
fail "case-
|
|
544
|
+
fail "case-10 escaping wrong ($ESC_OK): $ESC_BODY"
|
|
309
545
|
fi
|
|
310
546
|
|
|
311
547
|
# --- Summary ------------------------------------------------------------
|