From 8dedc0c6a473c1404eb5122e89613d7db50b8ca8 Mon Sep 17 00:00:00 2001 From: takecchi Date: Tue, 21 Jul 2026 13:38:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=82=B5=E3=83=96=E3=82=A8=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E7=A8=BC=E5=83=8D=E4=B8=AD?= =?UTF-8?q?=E3=81=AB=20result=20=E3=81=8C=E6=9D=A5=E3=81=A6=E3=82=82=20Com?= =?UTF-8?q?pleted=20=E3=81=AB=E5=80=92=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task ツールがバックグラウンド実行されると tool_result が即返り本体ターンが 続行するため、サブエージェント稼働中にトップレベルの result/success が先に 届く。これをそのまま completed 判定していたため、実際には作業継続中なのに バッジが「Completed」へ倒れる不具合があった。 - system/task_started / task_notification で稼働中タスク集合(activeTaskIds)を追跡 - result/success 受信時にタスクが残っていれば deferredResult に保留し running を維持 - 最後のタスクが settle した時点で保留結果を使って completed を確定(completeWith) - skip_transcript の雑務タスクはゲート対象外。late notification は failed を復活させない - 実データ採取のため spike に subagent シナリオを追加し、fixture でテスト SDK メッセージ形状は scripts/spike.ts の subagent シナリオで実採取し、 __fixtures__/session-subagent.jsonl として回帰テストに固定した。 --- docs/ARCHITECTURE.md | 15 ++- docs/TECH_NOTES.md | 2 + scripts/spike.ts | 16 ++- src/core/__fixtures__/session-subagent.jsonl | 17 +++ src/core/sdk-parse.spec.ts | 98 +++++++++++++++++ src/core/sdk-parse.ts | 107 ++++++++++++++++--- src/core/types.ts | 14 +++ 7 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/core/__fixtures__/session-subagent.jsonl diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de36bcf..cd88ac7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,7 +107,9 @@ codiva/ running ──(canUseTool 発火)───────────────▶ awaiting_permission awaiting_permission ──(ユーザー応答)────────▶ running running ──(result 受信 & 質問で終了)────────▶ awaiting_input - running ──(result 受信 & 正常終了)──────────▶ completed + running ──(result 受信 & 正常終了 & サブエージェント未稼働)──▶ completed + running ──(result 受信 & サブエージェント稼働中)──▶ running # 結果を deferredResult に保留し running 継続 + running ──(最後の task_notification で全タスク完了 & 保留結果あり)──▶ completed running ──(result subtype がエラー系)───────▶ failed running ──(レート制限に到達)─────────────────▶ rate_limited # rate_limit_event(rejected) / error='rate_limit' / usage-limit result・throw awaiting_input ──(追加指示送信)─────────────▶ running @@ -124,6 +126,17 @@ codiva/ `interrupted` に丸める(正常終了した `completed` とは区別する)。復元後は `completed` と同じく idle で resumable、追加指示で resume できる。 +**サブエージェント(Task ツール)の完了ゲート**: サブエージェントが **バックグラウンド実行**されると、 +その tool_result は即座に返り本体ターンは続行するため、サブエージェントがまだ稼働中でも**トップレベルの +`result/success` が先に届く**。この result をそのまま `completed` にすると、実際には作業継続中なのに +バッジが「Completed」へ倒れてしまう(本 issue の不具合)。対策として `system/task_started` / +`system/task_notification` で稼働中タスク集合(`activeTaskIds`)を追跡し、result 受信時にタスクが残って +いれば `completed` にせず結果を `deferredResult` に保留して `running` を維持する。最後のタスクが +`task_notification` で settle し集合が空になった時点で保留結果を使って `completed` を確定する +(`sdk-parse.ts` の `onTaskStarted` / `onTaskSettled` / `completeWith`)。`skip_transcript` の雑務タスクは +ゲート対象外。`activeTaskIds` / `deferredResult` は transient で永続しない。実データは +`__fixtures__/session-subagent.jsonl`(スパイクの `subagent` シナリオで採取)。 + `rate_limited` は「使用量/レート制限に達して止まった」セッションを表す。`completed`/`failed` と同じく idle だが、エラー扱い(`failed`)にはせず「制限が解けるのを待って再開できる」状態として区別する。 検知元は SDK の `rate_limit_event`(`rate_limit_info.status === 'rejected'`)、assistant メッセージの diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index f22b66b..be0ee02 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -109,6 +109,8 @@ const options = { **注意**: streaming input mode では `result` はターンの区切りごとに届き、セッション終了を意味しない。`result` 受信 = 「Claude のターンが終わり入力待ちになった」と解釈する(completed 判定はこのタイミング)。 +**サブエージェント(Task ツール)**: 本体エージェントが Task ツールで作業を委譲すると、サブエージェントのメッセージは `parent_tool_use_id`(= Task の tool_use id)付きで流れてくる(トップレベルは `null`)。サブエージェント自身は独自の `result` を出さず、**`result/success` は最後にトップレベル1件だけ**。ライフサイクルは `system/task_started` → `system/task_progress` → `system/task_notification`(`status: 'completed'|'failed'|'stopped'`)。Task が**バックグラウンド実行**されると tool_result が即返り本体ターンが続行するため、サブエージェント稼働中に `result/success` が先に届きうる。このとき素直に completed 判定すると「作業中なのに Completed」になる。対策は `task_started`/`task_notification` で稼働中タスクを追跡し、稼働中に届いた result を保留 → 全タスク settle 後に completed 確定(ARCHITECTURE.md「完了ゲート」参照)。実データは `scripts/spike.ts` の `subagent` シナリオで採取(`__fixtures__/session-subagent.jsonl`)。 + `includePartialMessages: true` にすると `{ type: 'stream_event', event }`(`SDKPartialAssistantMessage`)で生のストリーミングデルタが届く。**Phase 6 で採用**:`event.type === 'content_block_delta'` かつ `delta.type === 'text_delta'` のときのみ `delta.text` を `state.streamingText` に連結し、詳細ビューにタイピング風プレビューを出す。確定 `assistant` メッセージ / `result` / 追加入力で `streamingText` はクリア(確定ログが正)。`streamingText` は transient で永続しない。非テキストデルタ(`input_json_delta`・thinking 等)は状態を変えない。`~100ms` スロットル(`useSessions`)で再描画コストを抑える。 ### TODO進捗の抽出(Step n/m)— 要スパイク検証 diff --git a/scripts/spike.ts b/scripts/spike.ts index 89c55d9..d8639b8 100644 --- a/scripts/spike.ts +++ b/scripts/spike.ts @@ -22,7 +22,7 @@ import { join } from 'node:path'; import type { PermissionResult, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { query } from '@anthropic-ai/claude-agent-sdk'; -type Scenario = 'basic' | 'followup' | 'interrupt'; +type Scenario = 'basic' | 'followup' | 'interrupt' | 'subagent'; const positional = process.argv.slice(2).filter((a) => !a.startsWith('--')); const scenario = (positional[0] as Scenario) ?? 'basic'; @@ -39,6 +39,12 @@ const PROMPTS: Record = { interrupt: 'Count slowly: create files step1.txt, step2.txt, step3.txt, step4.txt, step5.txt one at a time, ' + 'each containing its number.', + subagent: + 'This is a test harness for observing sub-agent delegation. You MUST delegate the actual work ' + + 'to a sub-agent using the Task tool (subagent_type "general-purpose"): ask that sub-agent to ' + + 'create a file report.txt containing the single line "done by subagent" and then report back. ' + + 'Do not do the file work yourself in the main thread — delegate it. After the sub-agent finishes, ' + + 'tell me it is complete.', }; const prompt = PROMPTS[scenario]; @@ -216,6 +222,14 @@ async function main(): Promise { input.push('Now append the word "world" to notes.txt.'); continue; } + // subagent: close input but keep draining so any post-result background + // task settling (task_notification / background_tasks_changed / a 2nd + // result) is captured — we want to see how a result relates to sub-agent + // work still in flight rather than stopping at the first result. + if (scenario === 'subagent') { + input.close(); + continue; + } // basic / followup(after 2nd) / interrupt: end the session. input.close(); break; diff --git a/src/core/__fixtures__/session-subagent.jsonl b/src/core/__fixtures__/session-subagent.jsonl new file mode 100644 index 0000000..4a5b12d --- /dev/null +++ b/src/core/__fixtures__/session-subagent.jsonl @@ -0,0 +1,17 @@ +{"type":"system","subtype":"init","cwd":"/private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","ShareOnboardingGuide","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[],"model":"claude-opus-4-8[1m]","permissionMode":"acceptEdits","slash_commands":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator","agents","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","model","__remote-workflow","reload-skills","rename","review","security-review","usage-credits","extra-usage","usage","insights","recap","goal","design","design-consent","design-revoke","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.214","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator"],"plugins":[],"capabilities":["interrupt_receipt_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"c8e9828d-335a-4b4e-80a4-71b52485ca64","memory_paths":{"auto":"/Users/takecchi/.claude/projects/-private-var-folders-b7-gltn4sd52q53s30x0145ff-m0000gn-T-codiva-spike-9SQ2FK/memory/"},"fast_mode_state":"off"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1784620200,"rateLimitType":"five_hour","overageStatus":"allowed","overageResetsAt":1785542400,"isUsingOverage":false},"uuid":"d93fb800-6a87-4657-8cc7-58012cdac1f8","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"71156e64-9768-439b-b506-df9d6caca05e","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":130,"estimated_tokens_delta":80,"uuid":"fc38fe3f-104d-41b6-adfc-e1085bfe1fc4","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_011CdEaJ1PcNzBLRt7ciH3vN","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EoEECokBCA8YAipAvF3E3zqbdWlSunYCzxi6sEATo7EJCWNKU+FqFZzFu5zjm3QFD1GDt+QMEm3XAsMSMVhMn2OP8mKGHBXWHNymujIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQzNDE0MTA3NC05Y2FjLTQ0NmUtYjljYS04ZjcyZmQwZGY4Y2MSDJ8jVYaz0U1qUlfeUBoMJh5osymiCdcPW2rCIjBaMD9wZUqqcR1JlKVBJGub6MIKaBjhTdGDX52zm5+YJJ2I2YcugRJsHICfeSsMrMoqpAIXt1/bueuHkakcLWoxTz9l1L3P/yAOLyM+COI0oFcvZax+o5uMxnJ6+94i8fN3HeMO7NRO8u9JTIEalGff9+dF1Fn33wSzxzUH4+n42Dx3KVtyYoZt1Jn31Rsxwu+X7MN91J4yIwsTfXdoWSqapopD9/Yv9YYyePfK3Z6Fg2upfYmeUOmbow/GItvfct8NUK7C51C17ie1q56I8koAMGM6unWobRwv6JvzIDsFRZnwua0c9m0SJUnz9mPQU07kk5pUQ+1k3Ueap8u1KucPuUXigh27tXKOm+/XIx33c/QCEZpfqgqb1Wl4pfhmIiogHoXlf3AsNZNOEnHQrI5/SbrpXiacYVEJyDuhhsV2Wic43JAnzrxVxKk7n1Ularqc+1LjYdt8GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3303,"cache_read_input_tokens":16653,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3303},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"a5b80dda-6a64-49e2-8e70-14f0b929571f","timestamp":"2026-07-21T04:30:52.640Z","request_id":"req_011CdEaHzJ8dCzSbNzXq8k3s"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_011CdEaJ1PcNzBLRt7ciH3vN","type":"message","role":"assistant","content":[{"type":"text","text":"I'll delegate this to a general-purpose sub-agent."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3303,"cache_read_input_tokens":16653,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3303},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"337959f7-2ac6-43ab-a35c-be0f1e86350c","timestamp":"2026-07-21T04:30:53.307Z","request_id":"req_011CdEaHzJ8dCzSbNzXq8k3s"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_011CdEaJ1PcNzBLRt7ciH3vN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019o9hAhgK3grKNL1EMFUYsS","name":"Agent","input":{"description":"Create report.txt file","prompt":"Create a file named report.txt in the current working directory containing exactly one line with the text:\ndone by subagent\n\nThen confirm back that the file was created successfully.","subagent_type":"general-purpose","run_in_background":false},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3303,"cache_read_input_tokens":16653,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3303},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"585f8013-03f0-4c21-b70d-9a6ec9392c93","timestamp":"2026-07-21T04:30:54.532Z","request_id":"req_011CdEaHzJ8dCzSbNzXq8k3s"} +{"type":"system","subtype":"task_started","task_id":"a9c796e8b864ec7cf","tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","description":"Create report.txt file","subagent_type":"general-purpose","task_type":"local_agent","prompt":"Create a file named report.txt in the current working directory containing exactly one line with the text:\ndone by subagent\n\nThen confirm back that the file was created successfully.","uuid":"6a804fd3-b39f-4acc-9f3b-0b3576e2c5e7","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Create a file named report.txt in the current working directory containing exactly one line with the text:\ndone by subagent\n\nThen confirm back that the file was created successfully."}]},"parent_tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"274d25da-9716-459e-9347-8ef3e682243e","timestamp":"2026-07-21T04:30:54.538Z","subagent_type":"general-purpose","task_description":"Create report.txt file"} +{"type":"system","subtype":"task_progress","task_id":"a9c796e8b864ec7cf","tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","description":"Writing report.txt","subagent_type":"general-purpose","usage":{"total_tokens":11227,"tool_uses":1,"duration_ms":5548},"last_tool_name":"Write","uuid":"bf831faa-fd41-4e65-9adc-1525377d7695","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_011CdEaJRWgj6kcJwECfAu1i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016XfDiN2Sm6WDJYamETNhBN","name":"Write","input":{"file_path":"/private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike/report.txt","content":"done by subagent\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11217,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":11217,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"19b15bbc-dd04-4f85-83ff-bf8853701c37","timestamp":"2026-07-21T04:31:00.084Z","request_id":"req_011CdEaJQMyiPPLmiFCuVXFg","subagent_type":"general-purpose","task_description":"Create report.txt file"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016XfDiN2Sm6WDJYamETNhBN","type":"tool_result","content":"File created successfully at: /private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike/report.txt (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"007b60a4-1fe5-44ea-9343-3e0076278b65","timestamp":"2026-07-21T04:31:00.092Z","subagent_type":"general-purpose","task_description":"Create report.txt file"} +{"type":"system","subtype":"task_updated","task_id":"a9c796e8b864ec7cf","patch":{"status":"completed","end_time":1784608262605},"uuid":"bd58fa03-c80c-4d57-9b38-c56ce39e26ae","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"system","subtype":"task_notification","task_id":"a9c796e8b864ec7cf","tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","status":"completed","output_file":"/private/tmp/claude-501/-private-var-folders-b7-gltn4sd52q53s30x0145ff-m0000gn-T-codiva-spike-9SQ2FK--codiva-worktrees-spike/9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc/tasks/a9c796e8b864ec7cf.output","summary":"The file was created successfully at `/private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike/report.txt` containing exactly one line: `done by subagent`.","usage":{"total_tokens":11990,"tool_uses":1,"duration_ms":8067},"uuid":"979c04af-c5ae-4a09-8326-d2c031176849","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019o9hAhgK3grKNL1EMFUYsS","type":"tool_result","content":[{"type":"text","text":"The file was created successfully at `/private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike/report.txt` containing exactly one line: `done by subagent`."},{"type":"text","text":"agentId: a9c796e8b864ec7cf (use SendMessage with to: 'a9c796e8b864ec7cf', summary: '<5-10 word recap>' to continue this agent)\nsubagent_tokens: 12077\ntool_uses: 1\nduration_ms: 8068"}]}]},"parent_tool_use_id":null,"session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"a889f3c6-e94d-4bea-aa37-61eedb12d99b","timestamp":"2026-07-21T04:31:02.609Z","tool_use_result":{"status":"completed","prompt":"Create a file named report.txt in the current working directory containing exactly one line with the text:\ndone by subagent\n\nThen confirm back that the file was created successfully.","agentId":"a9c796e8b864ec7cf","agentType":"general-purpose","content":[{"type":"text","text":"The file was created successfully at `/private/var/folders/b7/gltn4sd52q53s30x0145ff_m0000gn/T/codiva-spike-9SQ2FK/.codiva/worktrees/spike/report.txt` containing exactly one line: `done by subagent`."}],"resolvedModel":"claude-opus-4-8[1m]","totalDurationMs":8068,"totalTokens":12077,"totalToolUseCount":1,"usage":{"input_tokens":2,"cache_creation_input_tokens":762,"cache_read_input_tokens":11217,"output_tokens":96,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":762},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":96,"cache_read_input_tokens":11217,"cache_creation_input_tokens":762,"cache_creation":{"ephemeral_5m_input_tokens":762,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"toolStats":{"readCount":0,"searchCount":0,"bashCount":0,"editFileCount":1,"linesAdded":2,"linesRemoved":0,"otherToolCount":0}}} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_011CdEaK327x9aAi3dLeDADG","type":"message","role":"assistant","content":[{"type":"text","text":"It's complete. The general-purpose sub-agent created `report.txt` containing the single line `done by subagent` and confirmed the file was written successfully."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":482,"cache_read_input_tokens":19956,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":482},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","uuid":"450c6b7a-8e8b-4091-bf45-58671b5053c0","timestamp":"2026-07-21T04:31:05.104Z","request_id":"req_011CdEaJzXoGeVvmn9jC4DoQ"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":16303,"duration_api_ms":17136,"ttft_ms":3824,"ttft_stream_ms":1745,"time_to_request_ms":136,"num_turns":2,"result":"It's complete. The general-purpose sub-agent created `report.txt` containing the single line `done by subagent` and confirmed the file was written successfully.","stop_reason":"end_turn","session_id":"9b2bf069-c3d2-4abb-8f5b-da5e93a72ffc","total_cost_usd":0.15174175,"usage":{"input_tokens":4,"cache_creation_input_tokens":3785,"cache_read_input_tokens":36609,"output_tokens":330,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":3785,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":50,"cache_read_input_tokens":19956,"cache_creation_input_tokens":482,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":482},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":615,"outputTokens":16,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000695,"contextWindow":200000,"maxOutputTokens":32000},"claude-opus-4-8[1m]":{"inputTokens":8,"outputTokens":575,"cacheReadInputTokens":47826,"cacheCreationInputTokens":15764,"webSearchRequests":0,"costUSD":0.15104675,"contextWindow":1000000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"c5c73671-0abb-4a3f-ab09-09a8e998adfe"} diff --git a/src/core/sdk-parse.spec.ts b/src/core/sdk-parse.spec.ts index 319f7bc..aa9c4b7 100644 --- a/src/core/sdk-parse.spec.ts +++ b/src/core/sdk-parse.spec.ts @@ -43,11 +43,13 @@ describe('applySdkMessage over real fixtures', () => { let basic: SDKMessage[]; let followup: SDKMessage[]; let interrupted: SDKMessage[]; + let subagent: SDKMessage[]; beforeAll(() => { basic = loadFixture('session-basic.jsonl'); followup = loadFixture('session-followup.jsonl'); interrupted = loadFixture('session-interrupt.jsonl'); + subagent = loadFixture('session-subagent.jsonl'); }); it('reaches completed on a successful session', () => { @@ -95,6 +97,15 @@ describe('applySdkMessage over real fixtures', () => { expect(state.status).toBe('failed'); expect(state.error).toContain('error'); }); + + it('reaches completed on a session that delegated to a sub-agent (Task tool)', () => { + // Real capture: the sub-agent settled (task_notification) before the top-level + // result, so the session completes cleanly and no task stays in flight. + const state = replay(subagent); + expect(state.status).toBe('completed'); + expect(state.activeTaskIds ?? []).toHaveLength(0); + expect(state.deferredResult).toBeUndefined(); + }); }); describe('applySdkMessage interaction with pending control state', () => { @@ -337,6 +348,93 @@ describe('applySdkMessage over rate-limit signals', () => { }); }); +describe('applySdkMessage gates completion on in-flight sub-agent tasks', () => { + const running: SessionState = { ...initialState(BASE), status: 'running' }; + const taskStarted = (task_id: string, extra: Record = {}) => ({ + type: 'system', + subtype: 'task_started', + task_id, + ...extra, + }); + const taskNotification = (task_id: string, status = 'completed') => ({ + type: 'system', + subtype: 'task_notification', + task_id, + status, + }); + const success = (result = 'all done') => ({ type: 'result', subtype: 'success', result }); + + it('does NOT complete while a backgrounded task is still running; defers the result', () => { + // A backgrounded Task returns its tool_result immediately, so the top-level + // `result` can arrive before the sub-agent finishes. The session must stay + // Running (not flip to Completed) until the task settles. + let state = sdk(running, taskStarted('t1'), 1); + expect(state.activeTaskIds).toEqual(['t1']); + state = sdk(state, success('done'), 2); + expect(state.status).toBe('running'); + expect(state.deferredResult).toMatchObject({ resultText: 'done' }); + // Result log is held back until the turn is truly done. + expect(state.messages.some((m) => m.kind === 'result')).toBe(false); + }); + + it('completes once the last sub-agent task settles after a deferred result', () => { + let state = sdk(running, taskStarted('t1'), 1); + state = sdk(state, success('done'), 2); + expect(state.status).toBe('running'); + state = sdk(state, taskNotification('t1'), 3); + expect(state.status).toBe('completed'); + expect(state.finishedAt).toBe(3); + expect(state.activeTaskIds ?? []).toHaveLength(0); + expect(state.deferredResult).toBeUndefined(); + expect(state.messages.at(-1)).toMatchObject({ kind: 'result', text: 'done' }); + }); + + it('waits for ALL tasks: a deferred result completes only when the set empties', () => { + let state = sdk(running, taskStarted('t1'), 1); + state = sdk(state, taskStarted('t2'), 2); + state = sdk(state, success(), 3); + expect(state.status).toBe('running'); + state = sdk(state, taskNotification('t1'), 4); + expect(state.status).toBe('running'); + expect(state.activeTaskIds).toEqual(['t2']); + state = sdk(state, taskNotification('t2'), 5); + expect(state.status).toBe('completed'); + }); + + it('completes immediately when a task settled before the result arrived (foreground)', () => { + let state = sdk(running, taskStarted('t1'), 1); + state = sdk(state, taskNotification('t1'), 2); + expect(state.status).toBe('running'); + expect(state.activeTaskIds).toEqual([]); + state = sdk(state, success('ok'), 3); + expect(state.status).toBe('completed'); + }); + + it('ignores ambient housekeeping tasks (skip_transcript) so they never gate completion', () => { + let state = sdk(running, taskStarted('t1', { skip_transcript: true }), 1); + expect(state.activeTaskIds).toBeUndefined(); + state = sdk(state, success('ok'), 2); + expect(state.status).toBe('completed'); + }); + + it('a late task_notification does not resurrect a failed session as completed', () => { + let state = sdk(running, taskStarted('t1'), 1); + // Turn errors while the task is still in flight. + state = sdk(state, { type: 'result', subtype: 'error_during_execution' }, 2); + expect(state.status).toBe('failed'); + state = sdk(state, taskNotification('t1'), 3); + expect(state.status).toBe('failed'); + }); + + it('deduplicates repeated task_started for the same id', () => { + let state = sdk(running, taskStarted('t1'), 1); + const before = state; + state = sdk(state, taskStarted('t1'), 2); + expect(state).toBe(before); + expect(state.activeTaskIds).toEqual(['t1']); + }); +}); + /** A partial-assistant stream_event (from includePartialMessages). */ function streamText(text: string) { return { diff --git a/src/core/sdk-parse.ts b/src/core/sdk-parse.ts index edbca52..e1752b4 100644 --- a/src/core/sdk-parse.ts +++ b/src/core/sdk-parse.ts @@ -112,6 +112,79 @@ function applyTaskTool(todos: TodoItem[], block: ToolUseBlock): TodoItem[] { return todos; } +/** + * Finalize a successful turn into `completed`, appending the result text (if any) + * to the log. Shared by the direct path (no sub-agent work in flight) and the + * deferred path (a `result` that had to wait for the last sub-agent task to settle). + */ +function completeWith( + state: SessionState, + result: { at: number; totalCostUsd?: number; resultText: string }, +): SessionState { + const withLog = + result.resultText.length > 0 + ? appendLog(state, 'result', result.resultText) + : { messages: state.messages, logSeq: state.logSeq }; + // Drop the transient deferral bookkeeping — the turn is genuinely done now. + const { deferredResult, activeTaskIds, ...rest } = state; + void deferredResult; + void activeTaskIds; + return { + ...rest, + status: 'completed', + finishedAt: result.at, + totalCostUsd: result.totalCostUsd, + streamingText: undefined, + messages: withLog.messages, + logSeq: withLog.logSeq, + }; +} + +/** + * `system/task_started`: a sub-agent (Task tool) began. Track its id so a `result` + * that arrives while it is still running is recognized as premature (a backgrounded + * Task returns its tool_result immediately and the top-level turn continues). Ambient + * housekeeping tasks (`skip_transcript`) are ignored — they must not gate completion. + */ +function onTaskStarted(state: SessionState, message: Record): SessionState { + if (message.skip_transcript === true) { + return state; + } + const taskId = typeof message.task_id === 'string' ? message.task_id : undefined; + if (taskId === undefined) { + return state; + } + const active = state.activeTaskIds ?? []; + if (active.includes(taskId)) { + return state; + } + return { ...state, activeTaskIds: [...active, taskId] }; +} + +/** + * `system/task_notification`: a sub-agent task settled (completed/failed/stopped). + * Drop it from the in-flight set; if that empties the set and a `result` was already + * deferred, finalize the completion now (the turn really is done). We only finalize + * a still-`running` session — a session that meanwhile failed/was aborted must not be + * flipped to completed by a late notification. + */ +function onTaskSettled( + state: SessionState, + message: Record, + at: number, +): SessionState { + const taskId = typeof message.task_id === 'string' ? message.task_id : undefined; + const active = state.activeTaskIds ?? []; + const nextActive = taskId ? active.filter((id) => id !== taskId) : active; + if (nextActive.length === 0 && state.deferredResult && state.status === 'running') { + return completeWith(state, { ...state.deferredResult, at }); + } + if (nextActive.length === active.length) { + return state; + } + return { ...state, activeTaskIds: nextActive }; +} + function reduceAssistant(state: SessionState, message: Record): SessionState { const inner = message.message as { content?: unknown; model?: unknown } | undefined; const content = Array.isArray(inner?.content) ? inner.content : []; @@ -251,6 +324,14 @@ function reduceSdk( model, }; } + // Sub-agent (Task tool) lifecycle — track in-flight tasks so a backgrounded + // Task can't let the top-level `result` mark the session completed early. + if (message.subtype === 'task_started') { + return onTaskStarted(state, message); + } + if (message.subtype === 'task_notification') { + return onTaskSettled(state, message, at); + } return state; } @@ -286,19 +367,19 @@ function reduceSdk( typeof message.total_cost_usd === 'number' ? message.total_cost_usd : state.totalCostUsd; if (message.subtype === 'success') { const resultText = asString(message.result); - const withLog = - resultText.length > 0 - ? appendLog(state, 'result', resultText) - : { messages: state.messages, logSeq: state.logSeq }; - return { - ...state, - status: 'completed', - finishedAt: at, - totalCostUsd: cost, - streamingText: undefined, - messages: withLog.messages, - logSeq: withLog.logSeq, - }; + // A sub-agent (Task) is still running: this top-level `result` arrived + // because the Task was backgrounded and returned its tool_result early. The + // session is NOT actually done — hold the result and stay `running` until + // the last task settles (`task_notification` → onTaskSettled finalizes it). + if ((state.activeTaskIds?.length ?? 0) > 0) { + return { + ...state, + totalCostUsd: cost, + streamingText: undefined, + deferredResult: { at, totalCostUsd: cost, resultText }, + }; + } + return completeWith(state, { at, totalCostUsd: cost, resultText }); } const error = String(message.subtype ?? 'error'); const resultText = asString(message.result); diff --git a/src/core/types.ts b/src/core/types.ts index f10a69f..d64d762 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -128,6 +128,20 @@ export interface SessionState { * cleared when the full message/result arrives; never persisted. */ streamingText?: string; + /** + * Task ids of sub-agent / Task-tool runs that have started (`system/task_started`) + * but not yet settled (`system/task_notification`). A backgrounded Task lets the + * top-level turn's `result` arrive while the sub-agent is still working; while this + * set is non-empty we must NOT treat that `result` as a real completion (the badge + * would flip to "Completed" mid-work). Transient runtime state; never persisted. + */ + activeTaskIds?: string[]; + /** + * A `result/success` that arrived while `activeTaskIds` was non-empty. We hold its + * payload here and stay `running`; completion is finalized once the last sub-agent + * task settles. Transient; never persisted. + */ + deferredResult?: { at: number; totalCostUsd?: number; resultText: string }; /** Internal monotonic counter for LogEntry.seq; keeps the reducer pure. */ logSeq: number; }