@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.3

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.
@@ -19,6 +19,34 @@ import type {
19
19
  import { toJsonRpcError } from "../../mcp/types";
20
20
  import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
21
21
 
22
+ /** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
23
+ interface FrameSink {
24
+ write(chunk: string): unknown;
25
+ flush(): unknown;
26
+ }
27
+
28
+ /**
29
+ * Write a newline-delimited JSON-RPC frame to the subprocess's stdin sink,
30
+ * swallowing synchronous errors so the caller can decide how to react.
31
+ *
32
+ * Bun's `FileSink` may throw synchronously (most reliably on Windows) when
33
+ * the read end of the pipe has been closed by a subprocess that exited
34
+ * between read-loop ticks. Letting that throw escape an `async` method
35
+ * surfaces as an unhandled promise rejection at the call site.
36
+ *
37
+ * Returns `true` when the frame was accepted by the sink, `false` when the
38
+ * sink threw — callers signal transport closure on `false`.
39
+ */
40
+ export function writeFrame(stdin: FrameSink, frame: string): boolean {
41
+ try {
42
+ stdin.write(frame);
43
+ stdin.flush();
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
22
50
  /**
23
51
  * Stdio transport for MCP servers.
24
52
  * Spawns a subprocess and communicates via stdin/stdout.
@@ -162,11 +190,7 @@ export class StdioTransport implements MCPTransport {
162
190
  const result = await this.onRequest(request.method, request.params);
163
191
  this.#sendResponse(request.id, result);
164
192
  } catch (error) {
165
- try {
166
- this.#sendResponse(request.id, undefined, toJsonRpcError(error));
167
- } catch {
168
- // Best-effort — process may have exited
169
- }
193
+ this.#sendResponse(request.id, undefined, toJsonRpcError(error));
170
194
  }
171
195
  }
172
196
 
@@ -175,8 +199,9 @@ export class StdioTransport implements MCPTransport {
175
199
  const response = error
176
200
  ? { jsonrpc: "2.0" as const, id, error }
177
201
  : { jsonrpc: "2.0" as const, id, result: result ?? {} };
178
- this.#process.stdin.write(`${JSON.stringify(response)}\n`);
179
- this.#process.stdin.flush();
202
+ // Silent on failure — a dead subprocess has no use for the response,
203
+ // and the read loop will close the transport on EOF.
204
+ writeFrame(this.#process.stdin, `${JSON.stringify(response)}\n`);
180
205
  }
181
206
 
182
207
  #handleClose(): void {
@@ -286,35 +311,43 @@ export class StdioTransport implements MCPTransport {
286
311
  params: params ?? {},
287
312
  };
288
313
 
289
- const message = `${JSON.stringify(notification)}\n`;
290
- // Bun's FileSink has write() method directly
291
- this.#process.stdin.write(message);
292
- this.#process.stdin.flush();
314
+ // Bun's FileSink can throw EPIPE synchronously on Windows when the
315
+ // subprocess has exited between the last read-loop tick and this
316
+ // write (e.g. an MCP server that dies after returning `initialize`
317
+ // but before `notifications/initialized` is delivered). Tear the
318
+ // transport down so any wired `onClose` (and reconnect machinery)
319
+ // engages, then surface the failure to the caller so a write that
320
+ // dropped on the floor is never silently treated as delivered —
321
+ // `initializeConnection()` runs before the manager installs its
322
+ // `onClose` handler, so a swallowed failure there would yield a
323
+ // "connected" handle wrapping a dead transport. See #1710.
324
+ if (!writeFrame(this.#process.stdin, `${JSON.stringify(notification)}\n`)) {
325
+ this.#handleClose();
326
+ throw new Error(`Transport closed while sending notification "${method}"`);
327
+ }
293
328
  }
294
329
 
295
330
  async close(): Promise<void> {
296
- if (!this.#connected) return;
297
- this.#connected = false;
298
-
299
- // Reject pending requests
300
- for (const [, pending] of this.#pendingRequests) {
301
- pending.reject(new Error("Transport closed"));
331
+ // `close()` is the authoritative resource teardown. `#handleClose()`
332
+ // may have already run (read-loop EOF, or a notify() write failure
333
+ // that surfaces the dead transport to the caller) and flipped
334
+ // `#connected` to false — but the subprocess and read loop are still
335
+ // alive in that path, so we MUST keep cleaning up regardless. Each
336
+ // step is individually guarded so this remains idempotent across
337
+ // repeat calls.
338
+ if (this.#connected) {
339
+ this.#handleClose();
302
340
  }
303
- this.#pendingRequests.clear();
304
341
 
305
- // Kill subprocess
306
342
  if (this.#process) {
307
343
  this.#process.kill();
308
344
  this.#process = null;
309
345
  }
310
346
 
311
- // Wait for read loop to finish
312
347
  if (this.#readLoop) {
313
348
  await this.#readLoop.catch(() => {});
314
349
  this.#readLoop = null;
315
350
  }
316
-
317
- this.onClose?.();
318
351
  }
319
352
  }
320
353
 
@@ -0,0 +1,31 @@
1
+ import { getReadToolPath, type ProtectedToolContext } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
2
+ import { normalizeLocalScheme } from "../tools/path-utils";
3
+
4
+ /** Canonical plan alias every session's `local://` root resolves. */
5
+ const LOCAL_PLAN_ALIAS = "local://PLAN.md";
6
+
7
+ /** True when `readPath` targets `planTarget`, ignoring `local:/` vs `local://`
8
+ * scheme spelling and any trailing read selector (`:1-50`, `:raw`, …). */
9
+ function readTargetsPlan(readPath: string, planTarget: string): boolean {
10
+ const read = normalizeLocalScheme(readPath);
11
+ const target = normalizeLocalScheme(planTarget);
12
+ return read === target || read.startsWith(`${target}:`);
13
+ }
14
+
15
+ /**
16
+ * Build a compaction protection matcher that keeps `read` results for the active
17
+ * plan file intact through prune/shake — the plan analog of skill-read
18
+ * protection. Matches both the canonical `local://PLAN.md` alias and the
19
+ * session's current plan reference path (e.g. a titled `local://<title>.md`), so
20
+ * the plan survives compaction whether the agent reads it by alias or by title.
21
+ *
22
+ * `getPlanReferencePath` is evaluated at match time so a mid-session retitle
23
+ * (plan approval renames `PLAN.md` → `<title>.md`) is honored immediately.
24
+ */
25
+ export function createPlanReadMatcher(getPlanReferencePath: () => string): (context: ProtectedToolContext) => boolean {
26
+ return (context: ProtectedToolContext) => {
27
+ const path = getReadToolPath(context);
28
+ if (path === undefined) return false;
29
+ return readTargetsPlan(path, LOCAL_PLAN_ALIAS) || readTargetsPlan(path, getPlanReferencePath());
30
+ };
31
+ }
@@ -59,12 +59,12 @@ output:
59
59
  Identify bugs the author would want fixed before merge.
60
60
 
61
61
  <procedure>
62
- 1. Run `git diff` (or `gh pr diff <number>`) to view patch
62
+ 1. Run `git diff`, `jj diff --git`, or `gh pr diff <number>` to view patch
63
63
  2. Read modified files for full context
64
64
  3. Call `report_finding` per issue
65
65
  4. Call `yield` with verdict
66
66
 
67
- Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. You NEVER make file edits or trigger builds.
67
+ Bash is read-only: `git diff`, `git log`, `git show`, `jj diff --git`, `gh pr diff`. You NEVER make file edits or trigger builds.
68
68
  </procedure>
69
69
 
70
70
  <criteria>
@@ -36,7 +36,7 @@ Group files by locality, e.g.:
36
36
 
37
37
  Reviewer MUST:
38
38
  1. Focus ONLY on assigned files
39
- 2. {{#if skipDiff}}MUST run `git diff`/`git show` for assigned files{{else}}MUST use diff hunks below (NEVER re-run git diff){{/if}}
39
+ 2. {{#if skipDiff}}{{diffInstruction}}{{else}}MUST use diff hunks below (NEVER re-run git diff){{/if}}
40
40
  3. MAY read full file context as needed via `read`
41
41
  4. Call `report_finding` per issue
42
42
  5. Call `yield` with verdict when done
@@ -0,0 +1,6 @@
1
+ <system-reminder>
2
+ The previous assistant turn ended with no text, reasoning, or tool call.
3
+ Continue the active task from the current context. If the work is complete, reply with a concise final summary instead of an empty response.
4
+
5
+ (Empty response retry {{retryCount}}/{{maxRetries}})
6
+ </system-reminder>
@@ -1,8 +1,15 @@
1
1
  Search hidden tool metadata to discover and activate tools.
2
2
 
3
3
  Activate hidden tools (MCP and built-in) when you need a capability not in your active tool set.
4
- {{#if hasDiscoverableMCPServers}}Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.{{/if}}
5
- {{#if discoverableMCPToolCount}}Total discoverable tools available: {{discoverableMCPToolCount}}.{{/if}}
4
+ {{#if hasDiscoverableMCPServers}}
5
+ Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.
6
+ {{/if}}
7
+ {{#if hasDiscoverableBuiltinTools}}
8
+ Discoverable built-in tools: {{#list discoverableBuiltinToolNames join=", "}}{{this}}{{/list}}.
9
+ {{/if}}
10
+ {{#if discoverableToolCount}}
11
+ Total discoverable tools available: {{discoverableToolCount}}.
12
+ {{/if}}
6
13
  Input:
7
14
  - `query` — required natural-language or keyword query
8
15
  - `limit` — optional maximum number of tools to return and activate (default `8`)
@@ -48,3 +48,11 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
48
48
  # Append tasks to a phase
49
49
  `{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
50
50
  </examples>
51
+
52
+ <critical>
53
+ When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
54
+ - You MUST `init` the list with EVERY item as its own task before doing the work.
55
+ - Enumerate all of them;
56
+ - NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
57
+ The entire point is to remember every one.
58
+ </critical>
@@ -55,6 +55,7 @@ import {
55
55
  shouldCompact,
56
56
  } from "@oh-my-pi/pi-agent-core/compaction";
57
57
  import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
58
+ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
58
59
  import type {
59
60
  AssistantMessage,
60
61
  Context,
@@ -163,9 +164,11 @@ import { parseTurnBudget } from "../modes/turn-budget";
163
164
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
164
165
  import { computeNonMessageTokens } from "../modes/utils/context-usage";
165
166
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
167
+ import { createPlanReadMatcher } from "../plan-mode/plan-protection";
166
168
  import type { PlanModeState } from "../plan-mode/state";
167
169
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
168
170
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
171
+ import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
169
172
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
170
173
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
171
174
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -275,6 +278,8 @@ export type AgentSessionEvent =
275
278
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
276
279
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
277
280
 
281
+ const EMPTY_STOP_MAX_RETRIES = 3;
282
+
278
283
  export interface AsyncJobSnapshot {
279
284
  running: AsyncJobSnapshotItem[];
280
285
  recent: AsyncJobSnapshotItem[];
@@ -995,6 +1000,7 @@ export class AgentSession {
995
1000
  #checkpointState: CheckpointState | undefined = undefined;
996
1001
  #pendingRewindReport: string | undefined = undefined;
997
1002
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1003
+ #emptyStopRetryCount = 0;
998
1004
  #promptGeneration = 0;
999
1005
  #providerSessionState = new Map<string, ProviderSessionState>();
1000
1006
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
@@ -1600,17 +1606,19 @@ export class AgentSession {
1600
1606
  if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
1601
1607
  const assistantEvent = event.assistantMessageEvent;
1602
1608
  let matchContext: TtsrMatchContext | undefined;
1609
+ let streamingToolCall: ToolCall | undefined;
1603
1610
 
1604
1611
  if (assistantEvent.type === "text_delta") {
1605
1612
  matchContext = { source: "text" };
1606
1613
  } else if (assistantEvent.type === "thinking_delta") {
1607
1614
  matchContext = { source: "thinking" };
1608
1615
  } else if (assistantEvent.type === "toolcall_delta") {
1609
- matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
1616
+ streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
1617
+ matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
1610
1618
  }
1611
1619
 
1612
1620
  if (matchContext && "delta" in assistantEvent) {
1613
- const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
1621
+ const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
1614
1622
  if (matches.length > 0) {
1615
1623
  // Decide first: a non-interrupting tool-source match attaches to the
1616
1624
  // specific tool call's result instead of driving a loop-wide follow-up.
@@ -1769,6 +1777,7 @@ export class AgentSession {
1769
1777
  if (
1770
1778
  assistantMsg.stopReason !== "error" &&
1771
1779
  assistantMsg.stopReason !== "aborted" &&
1780
+ !this.#isEmptyAssistantStop(assistantMsg) &&
1772
1781
  this.#retryAttempt > 0
1773
1782
  ) {
1774
1783
  if (this.#activeRetryFallback && this.model) {
@@ -1883,6 +1892,10 @@ export class AgentSession {
1883
1892
  }
1884
1893
  this.#lastSuccessfulYieldToolCallId = undefined;
1885
1894
 
1895
+ if (await this.#handleEmptyAssistantStop(msg)) {
1896
+ return;
1897
+ }
1898
+
1886
1899
  // Check for retryable errors first (overloaded, rate limit, server errors)
1887
1900
  if (this.#isRetryableError(msg)) {
1888
1901
  const didRetry = await this.#handleRetryableError(msg);
@@ -2276,30 +2289,64 @@ export class AgentSession {
2276
2289
  });
2277
2290
  }
2278
2291
 
2279
- /** Build TTSR match context for tool call argument deltas. */
2280
- #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
2281
- const context: TtsrMatchContext = { source: "tool" };
2292
+ /** Extract the tool-call block a toolcall_delta event refers to, if present. */
2293
+ #getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
2282
2294
  if (message.role !== "assistant") {
2283
- return context;
2295
+ return undefined;
2284
2296
  }
2285
2297
 
2286
2298
  const content = message.content;
2287
2299
  if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
2288
- return context;
2300
+ return undefined;
2289
2301
  }
2290
2302
 
2291
2303
  const block = content[contentIndex];
2292
2304
  if (!block || typeof block !== "object" || block.type !== "toolCall") {
2305
+ return undefined;
2306
+ }
2307
+
2308
+ return block as ToolCall;
2309
+ }
2310
+
2311
+ /** Build TTSR match context for tool call argument deltas. */
2312
+ #getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
2313
+ const context: TtsrMatchContext = { source: "tool" };
2314
+ if (!toolCall) {
2293
2315
  return context;
2294
2316
  }
2295
2317
 
2296
- const toolCall = block as ToolCall;
2297
2318
  context.toolName = toolCall.name;
2298
2319
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
2299
2320
  context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
2300
2321
  return context;
2301
2322
  }
2302
2323
 
2324
+ /**
2325
+ * Match a stream delta against TTSR rules.
2326
+ *
2327
+ * Tool argument streams prefer the tool's `matcherDigest` normalization — the
2328
+ * real content the call introduces — over the raw argument delta, so rule
2329
+ * conditions written against source text keep working regardless of the
2330
+ * tool's wire format (hashline patches, JSON-escaped strings, ...).
2331
+ */
2332
+ #checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
2333
+ const manager = this.#ttsrManager;
2334
+ if (!manager) {
2335
+ return [];
2336
+ }
2337
+ if (toolCall) {
2338
+ const tools = this.agent.state.tools;
2339
+ const tool =
2340
+ tools.find(t => t.name === toolCall.name) ??
2341
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2342
+ const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
2343
+ if (digest !== undefined) {
2344
+ return manager.checkSnapshot(digest, matchContext);
2345
+ }
2346
+ }
2347
+ return manager.checkDelta(delta, matchContext);
2348
+ }
2349
+
2303
2350
  /** Extract path-like arguments from tool call payload for TTSR glob matching. */
2304
2351
  #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
2305
2352
  if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -4274,6 +4321,7 @@ export class AgentSession {
4274
4321
 
4275
4322
  // Reset todo reminder count on new user prompt
4276
4323
  this.#todoReminderCount = 0;
4324
+ this.#emptyStopRetryCount = 0;
4277
4325
 
4278
4326
  await this.#maybeRestoreRetryFallbackPrimary();
4279
4327
 
@@ -5612,9 +5660,20 @@ export class AgentSession {
5612
5660
  // Compaction
5613
5661
  // =========================================================================
5614
5662
 
5663
+ /**
5664
+ * Append plan-read protection to a prune/shake config so the active plan
5665
+ * file survives compaction alongside skill reads (the config defaults
5666
+ * already carry skill protection). The matcher reads the current plan
5667
+ * reference path at match time, so retitled plans are covered.
5668
+ */
5669
+ #withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
5670
+ const planMatcher = createPlanReadMatcher(() => this.#planReferencePath);
5671
+ return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
5672
+ }
5673
+
5615
5674
  async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
5616
5675
  const branchEntries = this.sessionManager.getBranch();
5617
- const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
5676
+ const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG));
5618
5677
  if (result.prunedCount === 0) {
5619
5678
  return undefined;
5620
5679
  }
@@ -5695,7 +5754,7 @@ export class AgentSession {
5695
5754
  return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
5696
5755
  }
5697
5756
 
5698
- const config = opts.config ?? AGGRESSIVE_SHAKE_CONFIG;
5757
+ const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
5699
5758
  const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
5700
5759
  if (regions.length === 0) {
5701
5760
  return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
@@ -6287,6 +6346,99 @@ export class AgentSession {
6287
6346
  return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
6288
6347
  }
6289
6348
 
6349
+ async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
6350
+ if (!this.#isEmptyAssistantStop(assistantMessage)) {
6351
+ this.#emptyStopRetryCount = 0;
6352
+ return false;
6353
+ }
6354
+
6355
+ this.#emptyStopRetryCount++;
6356
+ if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
6357
+ logger.warn("Assistant returned empty stop after retry cap", {
6358
+ attempts: this.#emptyStopRetryCount - 1,
6359
+ model: assistantMessage.model,
6360
+ provider: assistantMessage.provider,
6361
+ });
6362
+ if (this.#retryAttempt > 0) {
6363
+ await this.#emitSessionEvent({
6364
+ type: "auto_retry_end",
6365
+ success: false,
6366
+ attempt: this.#retryAttempt,
6367
+ finalError: "Assistant returned empty stop after retry cap",
6368
+ });
6369
+ this.#retryAttempt = 0;
6370
+ }
6371
+ this.#resolveRetry();
6372
+ return true;
6373
+ }
6374
+
6375
+ this.#removeEmptyStopFromActiveContext(assistantMessage);
6376
+ this.agent.appendMessage({
6377
+ role: "developer",
6378
+ content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
6379
+ attribution: "agent",
6380
+ timestamp: Date.now(),
6381
+ });
6382
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
6383
+ return true;
6384
+ }
6385
+
6386
+ #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
6387
+ if (assistantMessage.stopReason !== "stop") return false;
6388
+ return !assistantMessage.content.some(content => {
6389
+ if (content.type === "text") return content.text.trim().length > 0;
6390
+ if (content.type === "thinking") return content.thinking.trim().length > 0;
6391
+ return content.type === "toolCall";
6392
+ });
6393
+ }
6394
+
6395
+ #emptyStopRetryReminder(): string {
6396
+ return prompt.render(emptyStopRetryTemplate, {
6397
+ retryCount: this.#emptyStopRetryCount,
6398
+ maxRetries: EMPTY_STOP_MAX_RETRIES,
6399
+ });
6400
+ }
6401
+
6402
+ #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
6403
+ const messages = this.agent.state.messages;
6404
+ const lastMessage = messages[messages.length - 1];
6405
+ if (
6406
+ lastMessage?.role === "assistant" &&
6407
+ this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
6408
+ ) {
6409
+ this.agent.replaceMessages(messages.slice(0, -1));
6410
+ }
6411
+
6412
+ const emptyStopEntry = this.sessionManager
6413
+ .getBranch()
6414
+ .slice()
6415
+ .reverse()
6416
+ .find(
6417
+ entry =>
6418
+ entry.type === "message" &&
6419
+ entry.message.role === "assistant" &&
6420
+ this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
6421
+ );
6422
+ if (!emptyStopEntry) {
6423
+ return;
6424
+ }
6425
+ if (emptyStopEntry.parentId === null) {
6426
+ this.sessionManager.resetLeaf();
6427
+ } else {
6428
+ this.sessionManager.branch(emptyStopEntry.parentId);
6429
+ }
6430
+ }
6431
+
6432
+ #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
6433
+ return (
6434
+ left === right ||
6435
+ (left.timestamp === right.timestamp &&
6436
+ left.provider === right.provider &&
6437
+ left.model === right.model &&
6438
+ left.stopReason === right.stopReason)
6439
+ );
6440
+ }
6441
+
6290
6442
  #enforceRewindBeforeYield(): boolean {
6291
6443
  if (!this.#checkpointState || this.#pendingRewindReport) {
6292
6444
  return false;
@@ -10,6 +10,7 @@ import {
10
10
  buildDiscoverableToolSearchIndex,
11
11
  type DiscoverableTool,
12
12
  type DiscoverableToolSearchIndex,
13
+ filterBySource,
13
14
  formatDiscoverableToolServerSummary,
14
15
  searchDiscoverableTools,
15
16
  summarizeDiscoverableTools,
@@ -141,10 +142,15 @@ function isDiscoveryEnabled(session: ToolSession): boolean {
141
142
 
142
143
  export function renderSearchToolBm25Description(discoverableTools: DiscoverableTool[] = []): string {
143
144
  const summary = summarizeDiscoverableTools(discoverableTools);
145
+ const builtinToolNames = filterBySource(discoverableTools, "builtin")
146
+ .map(t => t.name)
147
+ .sort();
144
148
  return prompt.render(searchToolBm25Description, {
145
- discoverableMCPToolCount: summary.toolCount,
149
+ discoverableToolCount: summary.toolCount,
146
150
  discoverableMCPServerSummaries: summary.servers.map(formatDiscoverableToolServerSummary),
147
151
  hasDiscoverableMCPServers: summary.servers.length > 0,
152
+ discoverableBuiltinToolNames: builtinToolNames,
153
+ hasDiscoverableBuiltinTools: builtinToolNames.length > 0,
148
154
  });
149
155
  }
150
156
 
@@ -273,6 +273,12 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
273
273
  readonly loadMode = "discoverable";
274
274
  readonly summary = "Write content to a file (creates or overwrites)";
275
275
 
276
+ /** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
277
+ matcherDigest(args: unknown): string | undefined {
278
+ const content = (args as Partial<WriteParams>).content;
279
+ return typeof content === "string" ? content : undefined;
280
+ }
281
+
276
282
  readonly #writethrough: WritethroughCallback;
277
283
 
278
284
  constructor(private readonly session: ToolSession) {