@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli.js +4967 -4948
  3. package/dist/types/config/model-registry.d.ts +1 -1
  4. package/dist/types/config/model-resolver.d.ts +5 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -0
  6. package/dist/types/dap/client.d.ts +19 -0
  7. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  9. package/dist/types/extensibility/typebox.d.ts +4 -0
  10. package/dist/types/hindsight/client.d.ts +10 -0
  11. package/dist/types/hindsight/config.d.ts +8 -0
  12. package/dist/types/mcp/tool-bridge.d.ts +9 -0
  13. package/dist/types/mnemopi/state.d.ts +2 -0
  14. package/dist/types/modes/components/user-message.d.ts +25 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +7 -1
  17. package/dist/types/modes/types.d.ts +14 -1
  18. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  19. package/dist/types/sdk.d.ts +1 -0
  20. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  21. package/dist/types/session/agent-session.d.ts +78 -2
  22. package/dist/types/task/executor.d.ts +10 -0
  23. package/dist/types/task/index.d.ts +1 -0
  24. package/dist/types/task/prewalk.d.ts +3 -0
  25. package/dist/types/tools/ask.d.ts +10 -0
  26. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  27. package/dist/types/utils/changelog.d.ts +4 -6
  28. package/package.json +12 -13
  29. package/src/cli/models-cli.ts +37 -17
  30. package/src/cli/update-cli.ts +14 -1
  31. package/src/config/model-registry.ts +47 -24
  32. package/src/config/model-resolver.ts +56 -31
  33. package/src/config/settings-schema.ts +5 -0
  34. package/src/config/settings.ts +68 -5
  35. package/src/dap/client.ts +86 -7
  36. package/src/edit/diff.ts +4 -4
  37. package/src/eval/py/prelude.py +5 -5
  38. package/src/export/html/template.js +23 -9
  39. package/src/extensibility/extensions/runner.ts +9 -4
  40. package/src/extensibility/extensions/types.ts +3 -0
  41. package/src/extensibility/typebox.ts +22 -9
  42. package/src/hindsight/client.test.ts +42 -0
  43. package/src/hindsight/client.ts +40 -3
  44. package/src/hindsight/config.ts +18 -0
  45. package/src/launch/broker.ts +31 -5
  46. package/src/lsp/index.ts +1 -1
  47. package/src/main.ts +15 -5
  48. package/src/mcp/tool-bridge.ts +9 -0
  49. package/src/mnemopi/state.ts +70 -3
  50. package/src/modes/components/agent-dashboard.ts +6 -2
  51. package/src/modes/components/chat-transcript-builder.ts +13 -2
  52. package/src/modes/components/diff.ts +2 -2
  53. package/src/modes/components/plan-review-overlay.ts +20 -4
  54. package/src/modes/components/user-message.ts +100 -1
  55. package/src/modes/controllers/command-controller.ts +22 -5
  56. package/src/modes/controllers/event-controller.ts +4 -1
  57. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  58. package/src/modes/controllers/input-controller.ts +47 -12
  59. package/src/modes/controllers/selector-controller.ts +82 -6
  60. package/src/modes/interactive-mode.ts +36 -4
  61. package/src/modes/types.ts +16 -3
  62. package/src/modes/utils/ui-helpers.ts +40 -20
  63. package/src/prompts/system/workflow-notice.md +1 -1
  64. package/src/sdk.ts +134 -48
  65. package/src/session/agent-session-error-log.test.ts +59 -0
  66. package/src/session/agent-session.ts +300 -26
  67. package/src/system-prompt.ts +1 -2
  68. package/src/task/executor.ts +41 -27
  69. package/src/task/index.ts +11 -0
  70. package/src/task/prewalk.ts +6 -0
  71. package/src/task/worktree.ts +25 -11
  72. package/src/tools/ask.ts +15 -0
  73. package/src/tools/bash.ts +14 -6
  74. package/src/tools/browser.ts +1 -1
  75. package/src/tools/debug.ts +1 -1
  76. package/src/tools/eval.ts +4 -5
  77. package/src/tools/fetch.ts +1 -1
  78. package/src/tools/hub/launch.ts +7 -0
  79. package/src/tools/tool-timeouts.ts +10 -3
  80. package/src/tools/write.ts +31 -0
  81. package/src/utils/changelog.ts +54 -58
  82. package/src/utils/git.ts +57 -49
@@ -30,6 +30,8 @@ import {
30
30
  type AgentMessage,
31
31
  type AgentState,
32
32
  type AgentTool,
33
+ type AgentToolCall,
34
+ type AgentToolContext,
33
35
  type AgentToolResult,
34
36
  type AgentTurnEndContext,
35
37
  AppendOnlyContextManager,
@@ -37,6 +39,7 @@ import {
37
39
  type CompactionSummaryMessage,
38
40
  countTokens,
39
41
  createToolScopedAbortReason,
42
+ isSyntheticToolResultMessage,
40
43
  resolveTelemetry,
41
44
  type StreamFn,
42
45
  TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -105,6 +108,7 @@ import type {
105
108
  TextContent,
106
109
  ToolCall,
107
110
  ToolChoice,
111
+ ToolResultMessage,
108
112
  Usage,
109
113
  UsageReport,
110
114
  } from "@oh-my-pi/pi-ai";
@@ -238,6 +242,7 @@ import type {
238
242
  TurnEndEvent,
239
243
  TurnStartEvent,
240
244
  } from "../extensibility/extensions";
245
+ import { emitSessionShutdownEvent } from "../extensibility/extensions";
241
246
  import { ManagedTimers } from "../extensibility/extensions/managed-timers";
242
247
  import { createExtensionModelQuery } from "../extensibility/extensions/model-api";
243
248
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
@@ -302,6 +307,7 @@ import { AgentRegistry } from "../registry/agent-registry";
302
307
  import {
303
308
  deobfuscateAssistantContent,
304
309
  deobfuscateSessionContext,
310
+ deobfuscateToolArguments,
305
311
  obfuscateMessages,
306
312
  obfuscateProviderContext,
307
313
  type SecretObfuscator,
@@ -320,6 +326,7 @@ import {
320
326
  } from "../thinking";
321
327
  import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
322
328
  import { shutdownTinyTitleClient } from "../tiny/title-client";
329
+ import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
323
330
  import { assertEditableFile } from "../tools/auto-generated-guard";
324
331
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
325
332
  import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
@@ -872,6 +879,16 @@ export interface PlanYolo {
872
879
  // Types
873
880
  // ============================================================================
874
881
 
882
+ /** Identifies a retry fallback chain already entered during startup model resolution. */
883
+ export interface InitialRetryFallbackState {
884
+ /** Role whose configured primary was unavailable. */
885
+ role: string;
886
+ /** Configured primary selector retained for restoration when it becomes available. */
887
+ originalSelector: string;
888
+ /** Thinking selector configured for the unavailable primary. */
889
+ originalThinkingLevel: ConfiguredThinkingLevel | undefined;
890
+ }
891
+
875
892
  export interface AgentSessionConfig {
876
893
  agent: Agent;
877
894
  sessionManager: SessionManager;
@@ -882,6 +899,8 @@ export interface AgentSessionConfig {
882
899
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
883
900
  /** Initial session thinking selector. */
884
901
  thinkingLevel?: ConfiguredThinkingLevel;
902
+ /** Retry chain ownership when startup selected one of its fallback entries. */
903
+ initialRetryFallback?: InitialRetryFallbackState;
885
904
  /** Prewalk from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */
886
905
  prewalk?: Prewalk;
887
906
  /** Force read-only plan mode at start, auto-approve on the model's first
@@ -1682,6 +1701,25 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
1682
1701
  return message.role === "custom" && message.customType === "advisor";
1683
1702
  }
1684
1703
 
1704
+ /**
1705
+ * Emit a warn-level log for a turn that ended in a provider error so recurring
1706
+ * stream failures are diagnosable from the main log alone. The `agent_end`
1707
+ * routing trace is debug-only and omits the error fields; without this a
1708
+ * session dying on provider errors leaves only `stopReason:"error"` debug lines
1709
+ * and the real cause lives solely in the session transcript (issue #6177).
1710
+ * No-op for any non-error stop reason.
1711
+ */
1712
+ export function logProviderTurnError(msg: AssistantMessage): void {
1713
+ if (msg.stopReason !== "error") return;
1714
+ logger.warn("agent turn ended with provider error", {
1715
+ provider: msg.provider,
1716
+ model: msg.model,
1717
+ errorMessage: msg.errorMessage,
1718
+ errorStatus: msg.errorStatus,
1719
+ errorId: msg.errorId,
1720
+ });
1721
+ }
1722
+
1685
1723
  function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
1686
1724
  if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
1687
1725
  let hasText = false;
@@ -2698,6 +2736,13 @@ export class AgentSession {
2698
2736
  } else {
2699
2737
  this.#thinkingLevel = config.thinkingLevel;
2700
2738
  }
2739
+ if (config.initialRetryFallback) {
2740
+ this.#activeRetryFallback = {
2741
+ ...config.initialRetryFallback,
2742
+ lastAppliedFallbackThinkingLevel: this.configuredThinkingLevel(),
2743
+ pinned: false,
2744
+ };
2745
+ }
2701
2746
  if (config.prewalk) {
2702
2747
  this.#prewalk = config.prewalk;
2703
2748
  }
@@ -4828,9 +4873,10 @@ export class AgentSession {
4828
4873
  if (toolName === "edit" && editedPath) {
4829
4874
  this.#invalidateFileCacheForPath(editedPath);
4830
4875
  }
4876
+ // TodoTool commits its state during execute. Replaying the result after
4877
+ // awaited event fan-out can overwrite a newer call from the same batch.
4831
4878
  const phases = details?.phases;
4832
4879
  if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
4833
- this.setTodoPhases(phases);
4834
4880
  if (this.#isTodoInitResult(details, toolCallId)) {
4835
4881
  this.#scheduleReplanTitleRefresh();
4836
4882
  }
@@ -4930,6 +4976,12 @@ export class AgentSession {
4930
4976
  };
4931
4977
  maintenanceRoute("entered");
4932
4978
 
4979
+ // Surface provider stream failures in the main log. The routing trace
4980
+ // above is debug-only and drops the error fields, so a session dying
4981
+ // repeatedly on provider errors otherwise leaves no actionable trace
4982
+ // outside the session transcript (issue #6177).
4983
+ logProviderTurnError(msg);
4984
+
4933
4985
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
4934
4986
  // aren't reused on the next request
4935
4987
  if (
@@ -5081,6 +5133,15 @@ export class AgentSession {
5081
5133
  if (this.#isClassifierRefusal(msg)) {
5082
5134
  this.#prunedTerminalRefusal = msg;
5083
5135
  this.#removeAssistantMessageFromActiveContext(msg);
5136
+ } else if (!AIError.isContextOverflow(msg, this.model?.contextWindow ?? 0)) {
5137
+ // No retry, fallback, or compaction continuation fired: this errored
5138
+ // turn ends the run. #persistSessionMessageIfMissing dropped it as an
5139
+ // empty error turn, so record it here — otherwise the JSONL stops at
5140
+ // the last tool result and the provider's errorMessage is lost (#6249).
5141
+ // Idempotent and a no-op for non-empty turns. Content-less overflow
5142
+ // rejections stay live-UI only per the auto-compaction progress guard:
5143
+ // persisting one would replay an empty assistant turn on reload.
5144
+ await this.#persistTerminalEmptyErrorTurn(msg);
5084
5145
  }
5085
5146
  this.#resolveRetry();
5086
5147
 
@@ -6399,6 +6460,10 @@ export class AgentSession {
6399
6460
  messages: AgentMessage[],
6400
6461
  lastAssistantMessage = this.getLastAssistantMessage(),
6401
6462
  ): Promise<boolean> {
6463
+ if (this.#abortInProgress || this.#isDisposed) {
6464
+ this.#resetSessionStopContinuationState();
6465
+ return false;
6466
+ }
6402
6467
  if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
6403
6468
  return false;
6404
6469
  }
@@ -6882,15 +6947,12 @@ export class AgentSession {
6882
6947
  this.#cancelExitRecorder?.();
6883
6948
  this.#cancelExitRecorder = undefined;
6884
6949
  try {
6885
- if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
6886
- await this.#extensionRunner.emit({ type: "session_shutdown" });
6887
- }
6950
+ await emitSessionShutdownEvent(this.#extensionRunner);
6888
6951
  } catch (error) {
6889
6952
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6890
6953
  }
6891
6954
 
6892
- // Stop extension timers before aborting deferred work they could enqueue.
6893
- this.#extensionRunner?.clearManagedTimers?.();
6955
+ // Stop fallback extension timers before aborting deferred work they could enqueue.
6894
6956
  this.#fallbackExtensionTimers?.clearAll();
6895
6957
  this.abortRetry();
6896
6958
  this.abortCompaction();
@@ -11988,7 +12050,17 @@ export class AgentSession {
11988
12050
  this.#pendingRecoveredRetryErrors = [];
11989
12051
  }
11990
12052
 
11991
- async #persistRetryLifecycleErrorMessage(message: AssistantMessage): Promise<void> {
12053
+ /**
12054
+ * Durably record a terminal empty error turn (`stopReason: "error"` with no
12055
+ * substantive content) that `#persistSessionMessageIfMissing` skipped, so the
12056
+ * session JSONL keeps a record of why the run stopped instead of ending at the
12057
+ * last tool result. A no-op for non-empty/non-error turns and idempotent via
12058
+ * the already-persisted guard; the turn is dropped from active context by the
12059
+ * caller (or `isProviderRefusalMessage`/`isEmptyErrorTurn` filters) so it is
12060
+ * never replayed on the wire. Used by the retry-lifecycle dead-ends and the
12061
+ * non-retry terminal error tail.
12062
+ */
12063
+ async #persistTerminalEmptyErrorTurn(message: AssistantMessage): Promise<void> {
11992
12064
  await this.#waitForSessionMessagePersistence(message);
11993
12065
  if (!isEmptyErrorTurn(message)) return;
11994
12066
  if (this.#sessionMessageAlreadyPersisted(message)) return;
@@ -12030,7 +12102,7 @@ export class AgentSession {
12030
12102
  id: number,
12031
12103
  options: { switchedCredential: boolean; switchedModel: boolean; delayMs: number },
12032
12104
  ): Promise<void> {
12033
- await this.#persistRetryLifecycleErrorMessage(message);
12105
+ await this.#persistTerminalEmptyErrorTurn(message);
12034
12106
  const persistenceKey = sessionMessagePersistenceKey(message);
12035
12107
  if (!persistenceKey) return;
12036
12108
  let branchEntry: SessionEntry | undefined;
@@ -14810,6 +14882,19 @@ export class AgentSession {
14810
14882
  return message.content.some(block => block.type === "toolCall");
14811
14883
  }
14812
14884
 
14885
+ /**
14886
+ * OpenRouter can repeatedly close Gemini streams at the reasoning-to-payload
14887
+ * transition. One retry covers a transient edge failure; the normal ten-retry
14888
+ * budget would otherwise re-run the same expensive reasoning cycle unchanged.
14889
+ */
14890
+ #isOpenRouterThinkingStreamClose(message: AssistantMessage): boolean {
14891
+ return (
14892
+ message.provider === "openrouter" &&
14893
+ /server_error:\s*stream closed with reason:\s*error/i.test(message.errorMessage ?? "") &&
14894
+ message.content.some(block => block.type === "thinking" && block.thinking.trim().length > 0)
14895
+ );
14896
+ }
14897
+
14813
14898
  #isClassifierRefusal(message: AssistantMessage): boolean {
14814
14899
  if (message.stopReason !== "error") return false;
14815
14900
  const stopType = message.stopDetails?.type;
@@ -15408,7 +15493,10 @@ export class AgentSession {
15408
15493
  // (every rotation sets switchedCredential and skips it), so without
15409
15494
  // this last resort a provider-wide usage cap never fails over to the
15410
15495
  // configured chain.
15411
- const retryBudgetExhausted = this.#retryAttempt > retrySettings.maxRetries;
15496
+ const maxRetries = this.#isOpenRouterThinkingStreamClose(message)
15497
+ ? Math.min(retrySettings.maxRetries, 1)
15498
+ : retrySettings.maxRetries;
15499
+ const retryBudgetExhausted = this.#retryAttempt > maxRetries;
15412
15500
 
15413
15501
  const errorMessage = message.errorMessage || "Unknown error";
15414
15502
  const id = this.#classifyRetryMessage(message);
@@ -15501,7 +15589,7 @@ export class AgentSession {
15501
15589
  }
15502
15590
  if (retryBudgetExhausted) {
15503
15591
  if (!switchedModel) {
15504
- await this.#persistRetryLifecycleErrorMessage(message);
15592
+ await this.#persistTerminalEmptyErrorTurn(message);
15505
15593
  // Max retries exceeded and no fallback model to switch to: emit
15506
15594
  // final failure and reset.
15507
15595
  await this.#emitSessionEvent({
@@ -15548,7 +15636,7 @@ export class AgentSession {
15548
15636
  // can act on it.
15549
15637
  const maxDelayMs = retrySettings.maxDelayMs;
15550
15638
  if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
15551
- await this.#persistRetryLifecycleErrorMessage(message);
15639
+ await this.#persistTerminalEmptyErrorTurn(message);
15552
15640
  const attempt = this.#retryAttempt;
15553
15641
  this.#retryAttempt = 0;
15554
15642
  await this.#emitSessionEvent({
@@ -15567,7 +15655,7 @@ export class AgentSession {
15567
15655
  await this.#emitSessionEvent({
15568
15656
  type: "auto_retry_start",
15569
15657
  attempt: this.#retryAttempt,
15570
- maxAttempts: retrySettings.maxRetries,
15658
+ maxAttempts: maxRetries,
15571
15659
  delayMs,
15572
15660
  errorMessage,
15573
15661
  errorId: message.errorId,
@@ -15693,20 +15781,40 @@ export class AgentSession {
15693
15781
  /**
15694
15782
  * Manually retry the last failed assistant turn.
15695
15783
  * Removes the error message from agent state and re-attempts with a fresh retry budget.
15784
+ *
15785
+ * A stream that stalls or aborts mid-tool-call ends the turn with
15786
+ * `stopReason: "error" | "aborted"` and then appends one synthetic
15787
+ * {@link isSyntheticToolResultMessage tool_result} per emitted tool call to
15788
+ * preserve the provider's tool_use/tool_result pairing (see
15789
+ * `createAbortedToolResult` in `agent-loop.ts`). Those placeholders trail the
15790
+ * failed assistant turn, so the retry lookback walks back over them before
15791
+ * checking the assistant message; it strips both the placeholders and the
15792
+ * failed turn before re-attempting.
15793
+ *
15696
15794
  * @returns true if retry was initiated, false if no failed turn to retry or agent is busy
15697
15795
  */
15698
15796
  async retry(): Promise<boolean> {
15699
15797
  if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
15700
15798
 
15701
15799
  const messages = this.agent.state.messages;
15702
- const lastMsg = messages[messages.length - 1];
15800
+
15801
+ // Walk back past trailing synthetic tool_result placeholders emitted for
15802
+ // tool calls that never ran because the turn stalled/aborted mid-tool-call.
15803
+ // They shadow the failed assistant turn from the single-message lookback.
15804
+ let turnEnd = messages.length;
15805
+ while (turnEnd > 0 && isSyntheticToolResultMessage(messages[turnEnd - 1])) {
15806
+ turnEnd--;
15807
+ }
15808
+
15809
+ const lastMsg = messages[turnEnd - 1];
15703
15810
  if (lastMsg?.role !== "assistant") return false;
15704
15811
 
15705
15812
  const assistantMsg = lastMsg as AssistantMessage;
15706
15813
  if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
15707
15814
 
15708
- // Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
15709
- this.agent.replaceMessages(messages.slice(0, -1));
15815
+ // Remove the failed/aborted assistant message plus its synthetic tool
15816
+ // results (same as auto-retry does before re-attempting).
15817
+ this.agent.replaceMessages(messages.slice(0, turnEnd - 1));
15710
15818
 
15711
15819
  // Reset retry budget for a fresh attempt
15712
15820
  this.#retryAttempt = 0;
@@ -15941,7 +16049,7 @@ export class AgentSession {
15941
16049
  signal: abortController.signal,
15942
16050
  sessionKey: target.sessionId,
15943
16051
  cwd,
15944
- timeout: clampTimeout("bash") * 1000,
16052
+ timeout: clampTimeout("bash", undefined, this.settings.get("tools.maxTimeout")) * 1000,
15945
16053
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
15946
16054
  useUserShell: options?.useUserShell,
15947
16055
  });
@@ -17023,7 +17131,30 @@ export class AgentSession {
17023
17131
  */
17024
17132
  async navigateTree(
17025
17133
  targetId: string,
17026
- options: { summarize?: boolean; customInstructions?: string } = {},
17134
+ options: {
17135
+ summarize?: boolean;
17136
+ customInstructions?: string;
17137
+ /**
17138
+ * Opts into the two-phase `ask` toolResult re-answer protocol
17139
+ * (issue #5642): set only by the interactive `/tree` selector, which
17140
+ * knows how to re-open the picker on `reopenAsk` and complete the
17141
+ * navigation with `reanswerAskResult`. Every other public caller
17142
+ * (extensions, hooks, ACP, session-extension actions) leaves this
17143
+ * unset and gets the pre-#5642 plain leaf move onto `ask`
17144
+ * toolResults instead — they have no picker to re-open and would
17145
+ * otherwise report a successful no-op navigation (roboomp review on
17146
+ * #5895).
17147
+ */
17148
+ allowAskReopen?: boolean;
17149
+ /**
17150
+ * Completes an in-progress `ask` re-answer (issue #5642): the caller
17151
+ * already received `reopenAsk` from a prior call on the same
17152
+ * `targetId`, re-opened the picker, and is handing back the fresh
17153
+ * answer. Branches a new toolResult sibling instead of landing on
17154
+ * the original one.
17155
+ */
17156
+ reanswerAskResult?: AgentToolResult<AskToolDetails>;
17157
+ } = {},
17027
17158
  ): Promise<{
17028
17159
  editorText?: string;
17029
17160
  cancelled: boolean;
@@ -17031,12 +17162,36 @@ export class AgentSession {
17031
17162
  summaryEntry?: BranchSummaryEntry;
17032
17163
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
17033
17164
  sessionContext?: SessionContext;
17165
+ /**
17166
+ * Set when `targetId` is an `ask` toolResult, `options.allowAskReopen`
17167
+ * was set, and `options.reanswerAskResult` was not supplied: nothing was
17168
+ * mutated. The caller must re-open the ask picker with these
17169
+ * `questions`, then call `navigateTree(targetId, { ...options,
17170
+ * reanswerAskResult })` with the produced result to actually branch
17171
+ * (issue #5642).
17172
+ */
17173
+ reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] };
17034
17174
  }> {
17035
17175
  await this.#flushPendingBashMessages();
17036
17176
  const oldLeafId = this.sessionManager.getLeafId();
17037
17177
 
17038
- // No-op if already at target
17039
- if (targetId === oldLeafId) {
17178
+ const targetEntry = this.sessionManager.getEntry(targetId);
17179
+ if (!targetEntry) {
17180
+ throw new Error(`Entry ${targetId} not found`);
17181
+ }
17182
+ const targetIsAskResult =
17183
+ targetEntry.type === "message" &&
17184
+ targetEntry.message.role === "toolResult" &&
17185
+ targetEntry.message.toolName === "ask";
17186
+
17187
+ // No-op if already at target — except mid-flight through the `ask`
17188
+ // re-answer protocol (issue #5642): a probe or completion call can
17189
+ // legitimately target the *current* leaf (e.g. the user interrupted
17190
+ // right after answering `ask`, before a follow-up assistant message
17191
+ // landed, or another caller navigated straight onto the ask result),
17192
+ // and must still return `reopenAsk` / branch the new answer instead of
17193
+ // silently reporting a no-op (chatgpt-codex review on #5895).
17194
+ if (targetId === oldLeafId && !(options.allowAskReopen && targetIsAskResult)) {
17040
17195
  return { cancelled: false };
17041
17196
  }
17042
17197
 
@@ -17045,16 +17200,47 @@ export class AgentSession {
17045
17200
  throw new Error("No model available for summarization");
17046
17201
  }
17047
17202
 
17048
- const targetEntry = this.sessionManager.getEntry(targetId);
17049
- if (!targetEntry) {
17050
- throw new Error(`Entry ${targetId} not found`);
17051
- }
17052
-
17053
- // Collect entries to summarize (from old leaf to common ancestor)
17203
+ // `ask` toolResult, first pass: hand control back to the caller to
17204
+ // re-open the picker instead of landing on the stale answer in place.
17205
+ // Nothing is mutated here — see the `reanswerAskResult` branch below for
17206
+ // the actual sibling-branch construction once the caller has an answer.
17207
+ // Gated on `allowAskReopen` — callers that don't understand `reopenAsk`
17208
+ // fall straight through to the plain leaf move below instead of
17209
+ // reporting a successful no-op (roboomp review on #5895).
17210
+ if (
17211
+ options.allowAskReopen &&
17212
+ !options.reanswerAskResult &&
17213
+ targetEntry.type === "message" &&
17214
+ targetEntry.message.role === "toolResult" &&
17215
+ targetEntry.message.toolName === "ask"
17216
+ ) {
17217
+ const toolCallId = targetEntry.message.toolCallId;
17218
+ const questions = this.#recoverAskReanswerQuestions(targetEntry.parentId, toolCallId);
17219
+ if (questions) {
17220
+ return { cancelled: false, reopenAsk: { toolCallId, questions } };
17221
+ }
17222
+ // Original arguments couldn't be recovered (corrupted/legacy session
17223
+ // data) — fall through to a plain leaf move so navigation still works.
17224
+ }
17225
+
17226
+ // Collect entries to summarize (from old leaf to common ancestor). For an
17227
+ // `ask` re-answer completion, the branch point is `targetEntry.parentId`
17228
+ // (the new sibling toolResult lands there, not on `targetId`) — anchor
17229
+ // the collection there too, or the old answer entry is neither on the
17230
+ // new branch nor included in the summary (chatgpt-codex review on
17231
+ // #5895).
17232
+ const summaryAnchorId =
17233
+ options.reanswerAskResult !== undefined &&
17234
+ targetEntry.type === "message" &&
17235
+ targetEntry.message.role === "toolResult" &&
17236
+ targetEntry.message.toolName === "ask" &&
17237
+ targetEntry.parentId !== null
17238
+ ? targetEntry.parentId
17239
+ : targetId;
17054
17240
  const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
17055
17241
  this.sessionManager,
17056
17242
  oldLeafId,
17057
- targetId,
17243
+ summaryAnchorId,
17058
17244
  );
17059
17245
 
17060
17246
  // Prepare event data
@@ -17150,6 +17336,27 @@ export class AgentSession {
17150
17336
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
17151
17337
  .map(c => c.text)
17152
17338
  .join("");
17339
+ } else if (
17340
+ targetEntry.type === "message" &&
17341
+ targetEntry.message.role === "toolResult" &&
17342
+ targetEntry.message.toolName === "ask" &&
17343
+ options.reanswerAskResult
17344
+ ) {
17345
+ // `ask` toolResult, second pass: the caller re-opened the picker and
17346
+ // is handing back a fresh answer. Branch a *new* sibling toolResult
17347
+ // off the same `ask` toolCall instead of reusing `targetId` — the
17348
+ // original answer's branch stays reachable (issue #5642).
17349
+ const reanswer = options.reanswerAskResult;
17350
+ const toolResultMessage: ToolResultMessage = {
17351
+ role: "toolResult",
17352
+ toolCallId: targetEntry.message.toolCallId,
17353
+ toolName: "ask",
17354
+ content: reanswer.content,
17355
+ details: reanswer.details,
17356
+ isError: reanswer.isError === true,
17357
+ timestamp: Date.now(),
17358
+ };
17359
+ newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId);
17153
17360
  } else {
17154
17361
  // Non-user message (or a user-invoked skill-prompt injection): land the
17155
17362
  // leaf on the selected node so it stays on the active branch. Skill
@@ -17212,6 +17419,73 @@ export class AgentSession {
17212
17419
  return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
17213
17420
  }
17214
17421
 
17422
+ /**
17423
+ * Look up the `ask` toolCall's persisted `arguments` and validate them
17424
+ * back into `questions`, for `/tree` `ask` re-answer (issue #5642). Walks
17425
+ * up from the toolResult's parent past any interleaved ancestor entries
17426
+ * — sibling toolResults from other tool calls in the same turn (`ask`
17427
+ * runs `exclusive`, which only serializes *execution*, not persistence
17428
+ * order — roboomp review on #5895), and bookkeeping entries such as the
17429
+ * `tool_execution_start` custom entry `#recordToolExecutionStart()`
17430
+ * appends before every toolResult in real persisted sessions (chatgpt-codex
17431
+ * review on #5895) — until it finds the assistant entry that actually
17432
+ * emitted `toolCallId`. Stops at a `user` message (turn boundary) or a
17433
+ * dead end. Returns `undefined` when no ancestor entry holds a matching
17434
+ * `ask` toolCall, or the arguments can't be resolved — the caller falls
17435
+ * back to a plain leaf move rather than opening a picker with bad data.
17436
+ */
17437
+ #recoverAskReanswerQuestions(parentId: string | null, toolCallId: string): AskToolInput["questions"] | undefined {
17438
+ let current = parentId;
17439
+ while (current !== null) {
17440
+ const entry = this.sessionManager.getEntry(current);
17441
+ if (!entry) return undefined;
17442
+ if (entry.type === "message") {
17443
+ if (entry.message.role === "assistant") {
17444
+ const toolCall = entry.message.content.find(
17445
+ (block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId,
17446
+ );
17447
+ if (!toolCall) return undefined;
17448
+ if (toolCall.name !== "ask") return undefined;
17449
+ const args = this.#obfuscator?.hasSecrets()
17450
+ ? deobfuscateToolArguments(this.#obfuscator, toolCall.arguments)
17451
+ : toolCall.arguments;
17452
+ return recoverAskQuestions(args);
17453
+ }
17454
+ if (entry.message.role === "user") return undefined;
17455
+ }
17456
+ current = entry.parentId;
17457
+ }
17458
+ return undefined;
17459
+ }
17460
+
17461
+ /**
17462
+ * Build a standalone `AgentToolContext` for running `AskTool.execute()`
17463
+ * outside a normal agent turn, for `/tree` `ask` re-answer (issue #5642).
17464
+ * `SelectorController` has no reachable `ToolContextStore` (that store is
17465
+ * built inside `sdk.ts` and never threaded through to mode controllers),
17466
+ * so this mirrors `refreshMCPTools()`'s `getCustomToolContext` factory
17467
+ * with real session state instead of a `{ ... } as unknown as
17468
+ * AgentToolContext` cast that could silently compile with an incomplete
17469
+ * context (roboomp review on #5895) — every `CustomToolContext` field is
17470
+ * backed by live session state, so a future required field fails to
17471
+ * compile here instead of surfacing as `undefined` at runtime.
17472
+ */
17473
+ buildAskReanswerContext(uiContext: ExtensionUIContext): AgentToolContext {
17474
+ return {
17475
+ sessionManager: this.sessionManager,
17476
+ modelRegistry: this.#modelRegistry,
17477
+ model: this.model,
17478
+ isIdle: () => !this.isStreaming,
17479
+ hasQueuedMessages: () => this.queuedMessageCount > 0,
17480
+ abort: () => {
17481
+ this.agent.abort();
17482
+ },
17483
+ settings: this.settings,
17484
+ ui: uiContext,
17485
+ hasUI: true,
17486
+ };
17487
+ }
17488
+
17215
17489
  /**
17216
17490
  * Get all user messages from session for branch selector.
17217
17491
  */
@@ -24,7 +24,6 @@ import projectPromptTemplate from "./prompts/system/project-prompt.md" with { ty
24
24
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
25
25
  import { normalizeConcurrencyLimit } from "./task/parallel";
26
26
  import { usesCodexTaskPrompt } from "./task/prompt-policy";
27
- import { shortenPath } from "./tools/render-utils";
28
27
  import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
29
28
  import { formatLocalCalendarDate } from "./utils/local-date";
30
29
  import { normalizePromptPath } from "./utils/prompt-path";
@@ -710,7 +709,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
710
709
 
711
710
  const date = formatLocalCalendarDate();
712
711
  const dateTime = date;
713
- const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
712
+ const promptCwd = normalizePromptPath(resolvedCwd);
714
713
  const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
715
714
 
716
715
  // Build tool metadata for system prompt rendering.