From fc672f7d81b161a376c7759de161ca993e04d099 Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Thu, 22 Oct 2026 22:31:37 +0000
Subject: [PATCH] fix(pi): continue the chat when a background subagent wakes
an idle parent (#31778)
---
src/main/pi/__fixtures__/subagent-wake.jsonl | 40 ++++++++++++++++++
src/main/pi/rpc-capture-replay.test.ts | 31 ++++++++++----
src/main/pi/rpc-session-adapter.test.ts | 10 +++++
src/main/pi/rpc-turn-races.test.ts | 14 +++++++
src/main/pi/rpc-turns.ts | 43 ++++++++++----------
5 files changed, 110 insertions(+), 28 deletions(-)
create mode 100644 src/main/pi/__fixtures__/subagent-wake.jsonl
diff --git a/src/main/pi/__fixtures__/subagent-wake.jsonl b/src/main/pi/__fixtures__/subagent-wake.jsonl
new file mode 100644
index 00000000000..7a2d699923f
--- /dev/null
+++ b/src/main/pi/__fixtures__/subagent-wake.jsonl
@@ -0,0 +1,40 @@
+{"dir":"in","raw":"{\"id\":\"q1\",\"type\":\"get_state\"}"}
+{"dir":"in","raw":"{\"type\":\"prompt\",\"message\":\"Run the review in a background subagent.\"}"}
+{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"wake-model\",\"name\":\"Wake model\",\"reasoning\":false,\"input\":[\"text\"],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":32000,\"maxTokens\":256,\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"baseUrl\":\"http://127.0.0.1:57389/v1\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/work/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"01a16115-e81d-730a-bfce-471c21136ac6\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"}
+{"dir":"out","raw":"{\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"}
+{"dir":"out","raw":"{\"type\":\"agent_start\"}"}
+{"dir":"out","raw":"{\"type\":\"turn_start\"}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\",\"tools\":\"\\n- read: Read file contents\\n- bash: Execute bash commands (ls, grep, find, etc.)\\n- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call\\n- write: Create or overwrite files\\n\\nIn addition to the tools above, you may have access to other custom tools depending on the project.\\n\",\"rules\":\"\\n- Use bash for file operations like ls, rg, find\\n- Use read to examine files instead of cat or sed.\\n- You can inspect PI_* environment variables for current model and session details.\\n- Use edit for precise changes (edits[].oldText must match exactly)\\n- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls\\n- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.\\n- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.\\n- Use write only for new files or complete rewrites.\\n- Be concise in your responses\\n- Show file paths clearly when working with files\\n\",\"docs\":\"\\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\\n- Main documentation: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/README.md\\n- Additional docs: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/docs\\n- Examples: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)\\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\\n\",\"cwd\":\"\\n/work/project\\n\"},\"timestamp\":1792630188263,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\",\"tools\":\"\\n- read: Read file contents\\n- bash: Execute bash commands (ls, grep, find, etc.)\\n- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call\\n- write: Create or overwrite files\\n\\nIn addition to the tools above, you may have access to other custom tools depending on the project.\\n\",\"rules\":\"\\n- Use bash for file operations like ls, rg, find\\n- Use read to examine files instead of cat or sed.\\n- You can inspect PI_* environment variables for current model and session details.\\n- Use edit for precise changes (edits[].oldText must match exactly)\\n- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls\\n- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.\\n- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.\\n- Use write only for new files or complete rewrites.\\n- Be concise in your responses\\n- Show file paths clearly when working with files\\n\",\"docs\":\"\\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\\n- Main documentation: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/README.md\\n- Additional docs: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/docs\\n- Examples: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)\\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\\n\",\"cwd\":\"\\n/work/project\\n\"},\"timestamp\":1792630188263,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Run the review in a background subagent.\"}],\"timestamp\":1792630188238}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Run the review in a background subagent.\"}],\"timestamp\":1792630188238}}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Started the background review.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1792630188339,\"responseId\":\"c1\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"Started the background review.\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"Started the background review.\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Started the background review.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188339,\"responseId\":\"c1\",\"rawStopReason\":\"stop\",\"durationMs\":37,\"thinkingLevel\":\"off\"}}"}
+{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Started the background review.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188339,\"responseId\":\"c1\",\"rawStopReason\":\"stop\",\"durationMs\":37,\"thinkingLevel\":\"off\"},\"toolResults\":[]}"}
+{"dir":"in","raw":"{\"id\":\"q2\",\"type\":\"get_session_stats\"}"}
+{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\",\"tools\":\"\\n- read: Read file contents\\n- bash: Execute bash commands (ls, grep, find, etc.)\\n- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call\\n- write: Create or overwrite files\\n\\nIn addition to the tools above, you may have access to other custom tools depending on the project.\\n\",\"rules\":\"\\n- Use bash for file operations like ls, rg, find\\n- Use read to examine files instead of cat or sed.\\n- You can inspect PI_* environment variables for current model and session details.\\n- Use edit for precise changes (edits[].oldText must match exactly)\\n- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls\\n- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.\\n- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.\\n- Use write only for new files or complete rewrites.\\n- Be concise in your responses\\n- Show file paths clearly when working with files\\n\",\"docs\":\"\\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\\n- Main documentation: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/README.md\\n- Additional docs: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/docs\\n- Examples: /opt/pi/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)\\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\\n\",\"cwd\":\"\\n/work/project\\n\"},\"timestamp\":1792630188263,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Run the review in a background subagent.\"}],\"timestamp\":1792630188238},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Started the background review.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188339,\"responseId\":\"c1\",\"rawStopReason\":\"stop\",\"durationMs\":37,\"thinkingLevel\":\"off\"}],\"willRetry\":false}"}
+{"dir":"out","raw":"{\"type\":\"agent_settled\",\"aborted\":false}"}
+{"dir":"in","raw":"{\"id\":\"q3\",\"type\":\"get_state\"}"}
+{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/work/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"01a16115-e81d-730a-bfce-471c21136ac6\",\"userMessages\":1,\"assistantMessages\":1,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":3,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":1450,\"contextWindow\":32000,\"percent\":4.53125}}}"}
+{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"wake-model\",\"name\":\"Wake model\",\"reasoning\":false,\"input\":[\"text\"],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":32000,\"maxTokens\":256,\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"baseUrl\":\"http://127.0.0.1:57389/v1\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/work/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"01a16115-e81d-730a-bfce-471c21136ac6\",\"autoCompactionEnabled\":true,\"messageCount\":3,\"pendingMessageCount\":0}}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"custom\",\"customType\":\"subagent-notify\",\"content\":\"Background subagent finished: review complete, no issues.\",\"display\":true,\"timestamp\":1792630188912}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"custom\",\"customType\":\"subagent-notify\",\"content\":\"Background subagent finished: review complete, no issues.\",\"display\":true,\"timestamp\":1792630188912}}"}
+{"dir":"out","raw":"{\"type\":\"agent_start\"}"}
+{"dir":"out","raw":"{\"type\":\"turn_start\"}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Inspect subagent updates above. Answer pending supervisor requests within your authority. For completed work, read saved results and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization.\"}],\"timestamp\":1792630188914}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Inspect subagent updates above. Answer pending supervisor requests within your authority. For completed work, read saved results and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization.\"}],\"timestamp\":1792630188914}}"}
+{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"WAKE_CONSUMED: the review found no issues.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1792630188915,\"responseId\":\"c2\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"WAKE_CONSUMED: the review found no issues.\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"WAKE_CONSUMED: the review found no issues.\"}}"}
+{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"WAKE_CONSUMED: the review found no issues.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188915,\"responseId\":\"c2\",\"rawStopReason\":\"stop\",\"durationMs\":4,\"thinkingLevel\":\"off\"}}"}
+{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"WAKE_CONSUMED: the review found no issues.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188915,\"responseId\":\"c2\",\"rawStopReason\":\"stop\",\"durationMs\":4,\"thinkingLevel\":\"off\"},\"toolResults\":[]}"}
+{"dir":"in","raw":"{\"id\":\"q4\",\"type\":\"get_session_stats\"}"}
+{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Inspect subagent updates above. Answer pending supervisor requests within your authority. For completed work, read saved results and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization.\"}],\"timestamp\":1792630188914},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"WAKE_CONSUMED: the review found no issues.\"}],\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"model\":\"wake-model\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792630188915,\"responseId\":\"c2\",\"rawStopReason\":\"stop\",\"durationMs\":4,\"thinkingLevel\":\"off\"}],\"willRetry\":false}"}
+{"dir":"out","raw":"{\"type\":\"agent_settled\",\"aborted\":false}"}
+{"dir":"in","raw":"{\"id\":\"q5\",\"type\":\"get_state\"}"}
+{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/work/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"01a16115-e81d-730a-bfce-471c21136ac6\",\"userMessages\":2,\"assistantMessages\":2,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":5,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":1556,\"contextWindow\":32000,\"percent\":4.8625}}}"}
+{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"wake-model\",\"name\":\"Wake model\",\"reasoning\":false,\"input\":[\"text\"],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":32000,\"maxTokens\":256,\"api\":\"openai-completions\",\"provider\":\"wake-fixture\",\"baseUrl\":\"http://127.0.0.1:57389/v1\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/work/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"01a16115-e81d-730a-bfce-471c21136ac6\",\"autoCompactionEnabled\":true,\"messageCount\":6,\"pendingMessageCount\":0}}"}
diff --git a/src/main/pi/rpc-capture-replay.test.ts b/src/main/pi/rpc-capture-replay.test.ts
index 1e19cb4da08..83ef3d044d3 100644
--- a/src/main/pi/rpc-capture-replay.test.ts
+++ b/src/main/pi/rpc-capture-replay.test.ts
@@ -98,6 +98,10 @@ async function replay(name: string) {
if (dir === 'out' && (frame.type !== 'response' || frame.command === 'prompt')) {
turns.receive(frame)
}
+ if (dir === 'out' && frame.type === 'response' && frame.command === 'get_state') {
+ // The idle probe resolved here in the captured run.
+ await new Promise((resolve) => setImmediate(resolve))
+ }
}
await new Promise((resolve) => setImmediate(resolve))
lane.flush()
@@ -105,6 +109,16 @@ async function replay(name: string) {
return { rows, turns: await rig.turns(), accepted, failed, idle, settled }
}
+function transcript(rows: Awaited>['rows']): string {
+ return rows
+ .flatMap((row) =>
+ row.body.kind === 'message'
+ ? row.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : []))
+ : []
+ )
+ .join('\n')
+}
+
describe('scrubbed Pi 1.0.4 RPC captures', () => {
it('records tool output, streamed text, and all three consumed inputs in one settled run', async () => {
const result = await replay('steer-followup')
@@ -115,13 +129,8 @@ describe('scrubbed Pi 1.0.4 RPC captures', () => {
expect(
result.rows.some((row) => row.body.kind === 'tool-call' && row.body.state === 'completed')
).toBe(true)
- const texts = result.rows.flatMap((row) =>
- row.body.kind === 'message'
- ? row.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : []))
- : []
- )
- expect(texts.join('\n')).toContain('STEER_CONSUMED')
- expect(texts.join('\n')).toContain('FOLLOWUP_CONSUMED')
+ expect(transcript(result.rows)).toContain('STEER_CONSUMED')
+ expect(transcript(result.rows)).toContain('FOLLOWUP_CONSUMED')
})
it.each(['provider-fetch-error', 'provider-socket-error'])(
'marks captured %s as failure with the provider diagnostic',
@@ -148,6 +157,14 @@ describe('scrubbed Pi 1.0.4 RPC captures', () => {
expect(result.turns[0]?.outcome).toBe('success')
expect(result.accepted).toHaveBeenCalledWith('send-1')
})
+ it('tracks the run a captured Pi 1.3.0 subagent wake starts on the idle parent', async () => {
+ const result = await replay('subagent-wake')
+ expect(result.failed).not.toHaveBeenCalled()
+ expect(result.accepted.mock.calls.map(([id]) => id)).toEqual(['send-1'])
+ expect(result.turns.map((turn) => turn.outcome)).toEqual(['success', 'success'])
+ expect(result.idle).toHaveBeenCalledTimes(2)
+ expect(transcript(result.rows)).toContain('WAKE_CONSUMED')
+ })
it('preserves empty answers and literal Unicode separators in captured dialogs', () => {
const dialogs = capture('dialogs').flatMap(({ dir, frame }) =>
dir === 'out' && frame.type === 'extension_ui_request' ? [frame] : []
diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts
index 13d46a3b47c..6f3a31ca997 100644
--- a/src/main/pi/rpc-session-adapter.test.ts
+++ b/src/main/pi/rpc-session-adapter.test.ts
@@ -329,6 +329,16 @@ describe('Pi RPC session ownership and delivery', () => {
expect(h.connection.closed).toBe(true)
})
+ it('stops a run Pi starts on an idle session without a send', async () => {
+ const h = await setup()
+ h.connection.receive({ type: 'agent_start' })
+ await vi.waitFor(async () => expect((await h.rig.turns())[0]?.state).toBe('running'))
+ expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true)
+ expect(h.connection.requests).toContain('abort')
+ h.connection.receive({ type: 'agent_settled' })
+ await vi.waitFor(async () => expect((await h.rig.turns())[0]?.outcome).toBe('cancellation'))
+ })
+
it('keeps a pending manual compaction cancellable and reports root/tree proof honestly', async () => {
const h = await setup()
const turnId = 'manual-compact'
diff --git a/src/main/pi/rpc-turn-races.test.ts b/src/main/pi/rpc-turn-races.test.ts
index 3f88013f60a..7b9703c5235 100644
--- a/src/main/pi/rpc-turn-races.test.ts
+++ b/src/main/pi/rpc-turn-races.test.ts
@@ -94,6 +94,20 @@ describe('Pi turn settlement races', () => {
expect(errors[0]?.text).toContain('`/login`')
}
})
+ it.each(['user', 'custom'])(
+ 'accepts a steer only at its own echo after a %s message starts an idle run',
+ async (role) => {
+ const h = await setup()
+ h.turns.receive({ type: 'agent_start' })
+ await h.turns.submit('steer', 1, { type: 'prompt', message: 'steer' })
+ h.turns.receive({ type: 'message_end', message: { role, content: [] } })
+ await h.flush()
+ expect(h.accepted).not.toHaveBeenCalled()
+ h.turns.receive({ type: 'message_end', message: { role: 'user', content: [] } })
+ await h.flush()
+ expect(h.accepted).toHaveBeenCalledWith('steer')
+ }
+ )
it('chooses steering after asynchronous dispatch admission finishes', async () => {
const h = await setup()
await h.turns.submit('first', 1, { type: 'prompt', message: 'first' })
diff --git a/src/main/pi/rpc-turns.ts b/src/main/pi/rpc-turns.ts
index e04f5a518ce..9d0755f377a 100644
--- a/src/main/pi/rpc-turns.ts
+++ b/src/main/pi/rpc-turns.ts
@@ -69,7 +69,7 @@ export class PiRpcTurns {
this.deps.lane.apply([{ type: 'input.accepted', clientMessageId: id, requestedAt: at }])
},
commandOnly: () => {
- this.skipInitialUserEcho = false
+ this.awaitingRunTrigger = false
this.settling = true
this.probe()
},
@@ -120,33 +120,35 @@ export class PiRpcTurns {
this.delivery.reply(frame)
}
return
- case 'agent_start':
- if (!this.active && !this.delivery.holdsDispatch) {
- throw new Error('Pi started an unsolicited agent run')
- }
+ case 'agent_start': {
+ // A run Pi starts while idle (an extension wake) opens with its own prompt.
+ const idle = !this.active
this.touch()
this.open(at)
- this.skipInitialUserEcho = this.delivery.consumeNext()
+ this.awaitingRunTrigger = this.delivery.consumeNext() || idle
return
+ }
case 'message_start':
case 'message_update':
case 'message_end': {
this.touch()
const message = piRpcMessageSchema.safeParse(frame.message)
- if (message.success && message.data.role === 'user' && frame.type === 'message_end') {
- // agent_start already accepted the first prompt; user echoes consume queued inputs.
- if (this.skipInitialUserEcho) {
- this.skipInitialUserEcho = false
- } else {
- this.delivery.consumeNext()
+ if (message.success && frame.type === 'message_end') {
+ const { role } = message.data
+ if (role !== 'system') {
+ // A run's first message is the prompt that started it; later user echoes consume queued inputs.
+ if (role === 'user' && !this.awaitingRunTrigger) {
+ this.delivery.consumeNext()
+ }
+ this.awaitingRunTrigger = false
+ }
+ if (role === 'assistant') {
+ this.lastStopReason = message.data.stopReason
+ this.failure =
+ message.data.stopReason === 'error'
+ ? (message.data.errorMessage ?? 'Pi request failed')
+ : undefined
}
- }
- if (message.success && message.data.role === 'assistant' && frame.type === 'message_end') {
- this.lastStopReason = message.data.stopReason
- this.failure =
- message.data.stopReason === 'error'
- ? (message.data.errorMessage ?? 'Pi request failed')
- : undefined
}
this.deps.lane.apply(this.messages.message(frame, at))
return
@@ -240,7 +242,7 @@ export class PiRpcTurns {
this.delivery.end()
}
- private skipInitialUserEcho = false
+ private awaitingRunTrigger = false
private open(at: number): void {
if (this.active) {
return
@@ -249,7 +251,6 @@ export class PiRpcTurns {
this.failure = undefined
this.lastStopReason = undefined
this.stopped = false
- this.skipInitialUserEcho = true
this.deps.lane.apply([{ type: 'turn.open', turn: this.active, at }])
}