@oh-my-pi/pi-coding-agent 13.15.2 → 13.16.0

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 (41) hide show
  1. package/CHANGELOG.md +26 -16
  2. package/package.json +7 -7
  3. package/src/config/keybindings.ts +6 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/extensions/types.ts +6 -1
  7. package/src/extensibility/hooks/types.ts +1 -1
  8. package/src/internal-urls/docs-index.generated.ts +1 -1
  9. package/src/modes/components/custom-editor.ts +6 -4
  10. package/src/modes/components/hook-editor.ts +57 -8
  11. package/src/modes/components/model-selector.ts +48 -29
  12. package/src/modes/components/settings-defs.ts +10 -1
  13. package/src/modes/components/settings-selector.ts +92 -5
  14. package/src/modes/controllers/extension-ui-controller.ts +32 -4
  15. package/src/modes/controllers/input-controller.ts +22 -9
  16. package/src/modes/controllers/selector-controller.ts +2 -2
  17. package/src/modes/interactive-mode.ts +7 -2
  18. package/src/modes/rpc/rpc-mode.ts +78 -30
  19. package/src/modes/rpc/rpc-types.ts +9 -1
  20. package/src/modes/theme/theme.ts +70 -0
  21. package/src/modes/types.ts +6 -1
  22. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  23. package/src/prompts/system/custom-system-prompt.md +5 -0
  24. package/src/prompts/system/system-prompt.md +6 -0
  25. package/src/prompts/tools/ask.md +1 -0
  26. package/src/prompts/tools/hashline.md +20 -5
  27. package/src/sdk.ts +9 -1
  28. package/src/session/agent-session.ts +338 -80
  29. package/src/session/messages.ts +23 -0
  30. package/src/session/session-manager.ts +65 -0
  31. package/src/system-prompt.ts +63 -2
  32. package/src/tools/ask.ts +109 -61
  33. package/src/tools/ast-edit.ts +2 -16
  34. package/src/tools/ast-grep.ts +2 -17
  35. package/src/tools/browser.ts +35 -17
  36. package/src/tools/grep.ts +4 -17
  37. package/src/tools/path-utils.ts +7 -0
  38. package/src/tools/render-utils.ts +27 -0
  39. package/src/tui/tree-list.ts +51 -22
  40. package/src/utils/image-input.ts +11 -1
  41. package/src/web/search/providers/codex.ts +10 -3
@@ -53,7 +53,7 @@ import {
53
53
  import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
54
54
  import type { AsyncJob, AsyncJobManager } from "../async";
55
55
  import type { Rule } from "../capability/rule";
56
- import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
56
+ import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
57
57
  import { extractExplicitThinkingSelector, parseModelString, resolveModelRoleValue } from "../config/model-resolver";
58
58
  import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
59
59
  import type { Settings, SkillsSettings } from "../config/settings";
@@ -269,7 +269,7 @@ export interface ModelCycleResult {
269
269
  export interface RoleModelCycleResult {
270
270
  model: Model;
271
271
  thinkingLevel: ThinkingLevel | undefined;
272
- role: ModelRole;
272
+ role: string;
273
273
  }
274
274
 
275
275
  /** Session statistics for /session command */
@@ -1600,16 +1600,29 @@ export class AgentSession {
1600
1600
  logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
1601
1601
  }
1602
1602
  await this.sessionManager.close();
1603
- for (const state of this.#providerSessionState.values()) {
1604
- state.close();
1605
- }
1606
- this.#providerSessionState.clear();
1603
+ this.#closeAllProviderSessions("dispose");
1607
1604
  this.#unsubscribePendingActionPush?.();
1608
1605
  this.#unsubscribePendingActionPush = undefined;
1609
1606
  this.#disconnectFromAgent();
1610
1607
  this.#eventListeners = [];
1611
1608
  }
1612
1609
 
1610
+ #closeAllProviderSessions(reason: string): void {
1611
+ for (const [providerKey, state] of this.#providerSessionState) {
1612
+ try {
1613
+ state.close();
1614
+ } catch (error) {
1615
+ logger.warn("Failed to close provider session state", {
1616
+ providerKey,
1617
+ reason,
1618
+ error: String(error),
1619
+ });
1620
+ }
1621
+ }
1622
+
1623
+ this.#providerSessionState.clear();
1624
+ }
1625
+
1613
1626
  // =========================================================================
1614
1627
  // Read-only State Access
1615
1628
  // =========================================================================
@@ -2026,7 +2039,7 @@ export class AgentSession {
2026
2039
  );
2027
2040
  }
2028
2041
 
2029
- resolveRoleModel(role: ModelRole): Model | undefined {
2042
+ resolveRoleModel(role: string): Model | undefined {
2030
2043
  return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
2031
2044
  }
2032
2045
 
@@ -2962,6 +2975,7 @@ export class AgentSession {
2962
2975
  this.#disconnectFromAgent();
2963
2976
  await this.abort();
2964
2977
  this.#asyncJobManager?.cancelAll();
2978
+ this.#closeAllProviderSessions("new session");
2965
2979
  this.agent.reset();
2966
2980
  await this.sessionManager.flush();
2967
2981
  await this.sessionManager.newSession(options);
@@ -3082,7 +3096,7 @@ export class AgentSession {
3082
3096
  * Validates API key, saves to session and settings.
3083
3097
  * @throws Error if no API key available for the model
3084
3098
  */
3085
- async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
3099
+ async setModel(model: Model, role: string = "default"): Promise<void> {
3086
3100
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3087
3101
  if (!apiKey) {
3088
3102
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3136,7 +3150,7 @@ export class AgentSession {
3136
3150
  * @param options - Optional settings: `temporary` to not persist to settings
3137
3151
  */
3138
3152
  async cycleRoleModels(
3139
- roleOrder: readonly ModelRole[],
3153
+ roleOrder: readonly string[],
3140
3154
  options?: { temporary?: boolean },
3141
3155
  ): Promise<RoleModelCycleResult | undefined> {
3142
3156
  const availableModels = this.#modelRegistry.getAvailable();
@@ -3146,7 +3160,7 @@ export class AgentSession {
3146
3160
  if (!currentModel) return undefined;
3147
3161
  const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() };
3148
3162
  const roleModels: Array<{
3149
- role: ModelRole;
3163
+ role: string;
3150
3164
  model: Model;
3151
3165
  thinkingLevel?: ThinkingLevel;
3152
3166
  explicitThinkingLevel: boolean;
@@ -3176,9 +3190,10 @@ export class AgentSession {
3176
3190
  if (roleModels.length <= 1) return undefined;
3177
3191
 
3178
3192
  const lastRole = this.sessionManager.getLastModelChangeRole();
3179
- let currentIndex = lastRole
3180
- ? roleModels.findIndex(entry => entry.role === lastRole)
3181
- : roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
3193
+ let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1;
3194
+ if (currentIndex === -1) {
3195
+ currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
3196
+ }
3182
3197
  if (currentIndex === -1) currentIndex = 0;
3183
3198
 
3184
3199
  const nextIndex = (currentIndex + 1) % roleModels.length;
@@ -4085,29 +4100,181 @@ export class AgentSession {
4085
4100
  }
4086
4101
 
4087
4102
  #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
4088
- if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
4103
+ const providerKeys = new Set<string>();
4104
+ if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
4105
+ providerKeys.add("openai-codex-responses");
4106
+ }
4107
+ if (currentModel.api === "openai-responses") {
4108
+ providerKeys.add(`openai-responses:${currentModel.provider}`);
4109
+ }
4110
+ if (nextModel.api === "openai-responses") {
4111
+ providerKeys.add(`openai-responses:${nextModel.provider}`);
4112
+ }
4089
4113
 
4090
- const providerKey = "openai-codex-responses";
4091
- const state = this.#providerSessionState.get(providerKey);
4092
- if (!state) return;
4114
+ for (const providerKey of providerKeys) {
4115
+ const state = this.#providerSessionState.get(providerKey);
4116
+ if (!state) continue;
4093
4117
 
4094
- try {
4095
- state.close();
4096
- } catch (error) {
4097
- logger.warn("Failed to close provider session state during model switch", {
4098
- providerKey,
4099
- error: String(error),
4100
- });
4118
+ try {
4119
+ state.close();
4120
+ } catch (error) {
4121
+ logger.warn("Failed to close provider session state during model switch", {
4122
+ providerKey,
4123
+ error: String(error),
4124
+ });
4125
+ }
4126
+
4127
+ this.#providerSessionState.delete(providerKey);
4101
4128
  }
4129
+ }
4102
4130
 
4103
- this.#providerSessionState.delete(providerKey);
4131
+ #normalizeProviderReplayValue(value: unknown): unknown {
4132
+ if (Array.isArray(value)) {
4133
+ return value.map(item => this.#normalizeProviderReplayValue(item));
4134
+ }
4135
+ if (value && typeof value === "object") {
4136
+ return Object.fromEntries(
4137
+ Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]),
4138
+ );
4139
+ }
4140
+ return value;
4141
+ }
4142
+
4143
+ #normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
4144
+ switch (message.role) {
4145
+ case "user":
4146
+ case "developer":
4147
+ return {
4148
+ role: message.role,
4149
+ content: this.#normalizeProviderReplayValue(message.content),
4150
+ providerPayload: message.providerPayload,
4151
+ };
4152
+ case "assistant": {
4153
+ const isResponsesFamilyMessage =
4154
+ message.api === "openai-responses" || message.api === "openai-codex-responses";
4155
+ return {
4156
+ role: message.role,
4157
+ content:
4158
+ isResponsesFamilyMessage && Array.isArray(message.content)
4159
+ ? message.content.flatMap(block => {
4160
+ if (block.type === "thinking") {
4161
+ return [];
4162
+ }
4163
+ if (block.type === "toolCall") {
4164
+ return [
4165
+ {
4166
+ type: block.type,
4167
+ id: block.id,
4168
+ name: block.name,
4169
+ arguments: block.arguments,
4170
+ },
4171
+ ];
4172
+ }
4173
+ if (block.type === "text") {
4174
+ return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
4175
+ }
4176
+ return [this.#normalizeProviderReplayValue(block)];
4177
+ })
4178
+ : this.#normalizeProviderReplayValue(message.content),
4179
+ api: message.api,
4180
+ provider: message.provider,
4181
+ model: message.model,
4182
+ stopReason: message.stopReason,
4183
+ errorMessage: message.errorMessage,
4184
+ providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
4185
+ };
4186
+ }
4187
+ case "toolResult":
4188
+ return {
4189
+ role: message.role,
4190
+ toolName: message.toolName,
4191
+ toolCallId: message.toolCallId,
4192
+ isError: message.isError,
4193
+ content: this.#normalizeProviderReplayValue(message.content),
4194
+ };
4195
+ case "bashExecution":
4196
+ return {
4197
+ role: message.role,
4198
+ command: message.command,
4199
+ output: message.output,
4200
+ exitCode: message.exitCode,
4201
+ cancelled: message.cancelled,
4202
+ meta: message.meta
4203
+ ? {
4204
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4205
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4206
+ diagnostics: message.meta.diagnostics
4207
+ ? this.#normalizeProviderReplayValue({
4208
+ summary: message.meta.diagnostics.summary,
4209
+ messages: message.meta.diagnostics.messages,
4210
+ })
4211
+ : undefined,
4212
+ }
4213
+ : undefined,
4214
+ excludeFromContext: message.excludeFromContext,
4215
+ };
4216
+ case "pythonExecution":
4217
+ return {
4218
+ role: message.role,
4219
+ code: message.code,
4220
+ output: message.output,
4221
+ exitCode: message.exitCode,
4222
+ cancelled: message.cancelled,
4223
+ meta: message.meta
4224
+ ? {
4225
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4226
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4227
+ diagnostics: message.meta.diagnostics
4228
+ ? this.#normalizeProviderReplayValue({
4229
+ summary: message.meta.diagnostics.summary,
4230
+ messages: message.meta.diagnostics.messages,
4231
+ })
4232
+ : undefined,
4233
+ }
4234
+ : undefined,
4235
+ excludeFromContext: message.excludeFromContext,
4236
+ };
4237
+ case "custom":
4238
+ case "hookMessage":
4239
+ return {
4240
+ role: message.role,
4241
+ customType: message.customType,
4242
+ content: this.#normalizeProviderReplayValue(message.content),
4243
+ };
4244
+ case "branchSummary":
4245
+ return { role: message.role, summary: message.summary };
4246
+ case "compactionSummary":
4247
+ return {
4248
+ role: message.role,
4249
+ summary: message.summary,
4250
+ providerPayload: message.providerPayload,
4251
+ };
4252
+ case "fileMention":
4253
+ return {
4254
+ role: message.role,
4255
+ files: message.files.map(file => ({
4256
+ path: file.path,
4257
+ content: file.content,
4258
+ image: file.image,
4259
+ })),
4260
+ };
4261
+ default:
4262
+ return this.#normalizeProviderReplayValue(message);
4263
+ }
4264
+ }
4265
+
4266
+ #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
4267
+ return (
4268
+ JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
4269
+ JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
4270
+ );
4104
4271
  }
4105
4272
 
4106
4273
  #getModelKey(model: Model): string {
4107
4274
  return `${model.provider}/${model.id}`;
4108
4275
  }
4109
4276
 
4110
- #formatRoleModelValue(role: ModelRole, model: Model): string {
4277
+ #formatRoleModelValue(role: string, model: Model): string {
4111
4278
  const modelKey = `${model.provider}/${model.id}`;
4112
4279
  const existingRoleValue = this.settings.getModelRole(role);
4113
4280
  if (!existingRoleValue) return modelKey;
@@ -4129,7 +4296,7 @@ export class AgentSession {
4129
4296
  return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
4130
4297
  }
4131
4298
 
4132
- #resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
4299
+ #resolveRoleModel(role: string, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
4133
4300
  const roleModelStr =
4134
4301
  role === "default"
4135
4302
  ? (this.settings.getModelRole("default") ??
@@ -5035,7 +5202,9 @@ export class AgentSession {
5035
5202
  */
5036
5203
  async switchSession(sessionPath: string): Promise<boolean> {
5037
5204
  const previousSessionFile = this.sessionManager.getSessionFile();
5038
-
5205
+ const switchingToDifferentSession = previousSessionFile
5206
+ ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
5207
+ : true;
5039
5208
  // Emit session_before_switch event (can be cancelled)
5040
5209
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
5041
5210
  const result = (await this.#extensionRunner.emit({
@@ -5051,68 +5220,149 @@ export class AgentSession {
5051
5220
 
5052
5221
  this.#disconnectFromAgent();
5053
5222
  await this.abort();
5223
+
5224
+ // Flush pending writes before switching so restore snapshots reflect committed state.
5225
+ await this.sessionManager.flush();
5226
+ const previousSessionState = this.sessionManager.captureState();
5227
+ const previousSessionContext = this.sessionManager.buildSessionContext();
5228
+ // switchSession replaces these arrays wholesale during load/rollback, so retaining
5229
+ // the existing message objects is sufficient and avoids structured-clone failures for
5230
+ // extension/custom metadata that is valid to persist but not cloneable.
5231
+ const previousAgentMessages = [...this.agent.state.messages];
5232
+ const previousSteeringMessages = [...this.#steeringMessages];
5233
+ const previousFollowUpMessages = [...this.#followUpMessages];
5234
+ const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
5235
+ const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
5236
+ const previousModel = this.model;
5237
+ const previousThinkingLevel = this.#thinkingLevel;
5238
+ const previousServiceTier = this.agent.serviceTier;
5239
+ const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
5240
+ const previousTools = [...this.agent.state.tools];
5241
+ const previousBaseSystemPrompt = this.#baseSystemPrompt;
5242
+ const previousSystemPrompt = this.agent.state.systemPrompt;
5243
+ const previousFallbackSelectedMCPToolNames = previousSessionFile
5244
+ ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
5245
+ : undefined;
5246
+
5054
5247
  this.#steeringMessages = [];
5055
5248
  this.#followUpMessages = [];
5056
5249
  this.#pendingNextTurnMessages = [];
5057
5250
  this.#scheduledHiddenNextTurnGeneration = undefined;
5058
5251
 
5059
- // Flush pending writes before switching
5060
- await this.sessionManager.flush();
5061
-
5062
- // Set new session
5063
- await this.sessionManager.setSessionFile(sessionPath);
5064
- this.agent.sessionId = this.sessionManager.getSessionId();
5065
-
5066
- // Reload messages
5067
- const sessionContext = this.sessionManager.buildSessionContext();
5068
- const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
5069
- await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
5252
+ try {
5253
+ await this.sessionManager.setSessionFile(sessionPath);
5254
+ this.agent.sessionId = this.sessionManager.getSessionId();
5070
5255
 
5071
- // Emit session_switch event to hooks
5072
- if (this.#extensionRunner) {
5073
- await this.#extensionRunner.emit({
5074
- type: "session_switch",
5075
- reason: "resume",
5076
- previousSessionFile,
5077
- });
5078
- }
5256
+ const sessionContext = this.sessionManager.buildSessionContext();
5257
+ const didReloadConversationChange =
5258
+ !switchingToDifferentSession &&
5259
+ this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
5260
+ const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
5261
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
5079
5262
 
5080
- this.agent.replaceMessages(sessionContext.messages);
5081
- this.#syncTodoPhasesFromBranch();
5263
+ // Emit session_switch event to hooks
5264
+ if (this.#extensionRunner) {
5265
+ await this.#extensionRunner.emit({
5266
+ type: "session_switch",
5267
+ reason: "resume",
5268
+ previousSessionFile,
5269
+ });
5270
+ }
5082
5271
 
5083
- // Restore model if saved
5084
- const defaultModelStr = sessionContext.models.default;
5085
- if (defaultModelStr) {
5086
- const slashIdx = defaultModelStr.indexOf("/");
5087
- if (slashIdx > 0) {
5088
- const provider = defaultModelStr.slice(0, slashIdx);
5089
- const modelId = defaultModelStr.slice(slashIdx + 1);
5090
- const availableModels = this.#modelRegistry.getAvailable();
5091
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
5092
- if (match) {
5093
- this.#setModelWithProviderSessionReset(match);
5272
+ this.agent.replaceMessages(sessionContext.messages);
5273
+ this.#syncTodoPhasesFromBranch();
5274
+ if (switchingToDifferentSession) {
5275
+ this.#closeAllProviderSessions("session switch");
5276
+ } else if (didReloadConversationChange) {
5277
+ this.#closeAllProviderSessions("session reload");
5278
+ }
5279
+
5280
+ // Restore model if saved
5281
+ const defaultModelStr = sessionContext.models.default;
5282
+ if (defaultModelStr) {
5283
+ const slashIdx = defaultModelStr.indexOf("/");
5284
+ if (slashIdx > 0) {
5285
+ const provider = defaultModelStr.slice(0, slashIdx);
5286
+ const modelId = defaultModelStr.slice(slashIdx + 1);
5287
+ const availableModels = this.#modelRegistry.getAvailable();
5288
+ const match = availableModels.find(m => m.provider === provider && m.id === modelId);
5289
+ if (match) {
5290
+ const currentModel = this.model;
5291
+ const shouldResetProviderState =
5292
+ switchingToDifferentSession ||
5293
+ (currentModel !== undefined &&
5294
+ (currentModel.provider !== match.provider ||
5295
+ currentModel.id !== match.id ||
5296
+ currentModel.api !== match.api));
5297
+ if (shouldResetProviderState) {
5298
+ this.#setModelWithProviderSessionReset(match);
5299
+ } else {
5300
+ this.agent.setModel(match);
5301
+ }
5302
+ }
5094
5303
  }
5095
5304
  }
5096
- }
5097
5305
 
5098
- const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5099
- const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
5100
- const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5101
- const configuredServiceTier = this.settings.get("serviceTier");
5102
- const nextThinkingLevel = resolveThinkingLevelForModel(
5103
- this.model,
5104
- hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
5105
- );
5106
- this.#thinkingLevel = nextThinkingLevel;
5107
- this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
5108
- this.agent.serviceTier = hasServiceTierEntry
5109
- ? sessionContext.serviceTier
5110
- : configuredServiceTier === "none"
5111
- ? undefined
5112
- : configuredServiceTier;
5306
+ const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5307
+ const hasServiceTierEntry = this.sessionManager
5308
+ .getBranch()
5309
+ .some(entry => entry.type === "service_tier_change");
5310
+ const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5311
+ const configuredServiceTier = this.settings.get("serviceTier");
5312
+ const nextThinkingLevel = resolveThinkingLevelForModel(
5313
+ this.model,
5314
+ hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
5315
+ );
5316
+ this.#thinkingLevel = nextThinkingLevel;
5317
+ this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
5318
+ this.agent.serviceTier = hasServiceTierEntry
5319
+ ? sessionContext.serviceTier
5320
+ : configuredServiceTier === "none"
5321
+ ? undefined
5322
+ : configuredServiceTier;
5113
5323
 
5114
- this.#reconnectToAgent();
5115
- return true;
5324
+ this.#reconnectToAgent();
5325
+ return true;
5326
+ } catch (error) {
5327
+ this.sessionManager.restoreState(previousSessionState);
5328
+ this.agent.sessionId = previousSessionState.sessionId;
5329
+ let restoreMcpError: unknown;
5330
+ try {
5331
+ await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
5332
+ fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
5333
+ });
5334
+ } catch (mcpError) {
5335
+ restoreMcpError = mcpError;
5336
+ logger.warn("Failed to restore MCP selections after switch error", {
5337
+ previousSessionFile,
5338
+ targetSessionFile: sessionPath,
5339
+ error: String(mcpError),
5340
+ });
5341
+ this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
5342
+ this.agent.setTools(previousTools);
5343
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5344
+ this.agent.setSystemPrompt(previousSystemPrompt);
5345
+ }
5346
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5347
+ this.agent.setSystemPrompt(previousSystemPrompt);
5348
+ this.agent.replaceMessages(previousAgentMessages);
5349
+ this.#steeringMessages = previousSteeringMessages;
5350
+ this.#followUpMessages = previousFollowUpMessages;
5351
+ this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
5352
+ this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
5353
+ if (previousModel) {
5354
+ this.agent.setModel(previousModel);
5355
+ }
5356
+ this.#thinkingLevel = previousThinkingLevel;
5357
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
5358
+ this.agent.serviceTier = previousServiceTier;
5359
+ this.#syncTodoPhasesFromBranch();
5360
+ this.#reconnectToAgent();
5361
+ if (restoreMcpError) {
5362
+ throw restoreMcpError;
5363
+ }
5364
+ throw error;
5365
+ }
5116
5366
  }
5117
5367
 
5118
5368
  /**
@@ -5124,7 +5374,10 @@ export class AgentSession {
5124
5374
  * - selectedText: The text of the selected user message (for editor pre-fill)
5125
5375
  * - cancelled: True if a hook cancelled the branch
5126
5376
  */
5127
- async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
5377
+ async branch(entryId: string): Promise<{
5378
+ selectedText: string;
5379
+ cancelled: boolean;
5380
+ }> {
5128
5381
  const previousSessionFile = this.sessionFile;
5129
5382
  const selectedEntry = this.sessionManager.getEntry(entryId);
5130
5383
 
@@ -5202,7 +5455,12 @@ export class AgentSession {
5202
5455
  async navigateTree(
5203
5456
  targetId: string,
5204
5457
  options: { summarize?: boolean; customInstructions?: string } = {},
5205
- ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
5458
+ ): Promise<{
5459
+ editorText?: string;
5460
+ cancelled: boolean;
5461
+ aborted?: boolean;
5462
+ summaryEntry?: BranchSummaryEntry;
5463
+ }> {
5206
5464
  const oldLeafId = this.sessionManager.getLeafId();
5207
5465
 
5208
5466
  // No-op if already at target
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
8
  import type {
9
+ AssistantMessage,
9
10
  ImageContent,
10
11
  Message,
11
12
  MessageAttribution,
@@ -213,6 +214,28 @@ export function createCompactionSummaryMessage(
213
214
  };
214
215
  }
215
216
 
217
+ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage {
218
+ if (message.providerPayload?.type !== "openaiResponsesHistory") {
219
+ return message;
220
+ }
221
+
222
+ let didSanitize = false;
223
+ const sanitizedContent = message.content.map(block => {
224
+ if (block.type !== "thinking" || block.thinkingSignature === undefined) {
225
+ return block;
226
+ }
227
+
228
+ didSanitize = true;
229
+ return { ...block, thinkingSignature: undefined };
230
+ });
231
+
232
+ if (!didSanitize) {
233
+ return message;
234
+ }
235
+
236
+ return { ...message, content: sanitizedContent };
237
+ }
238
+
216
239
  /** Convert CustomMessageEntry to AgentMessage format */
217
240
  export function createCustomMessage(
218
241
  customType: string,
@@ -46,6 +46,7 @@ import {
46
46
  type FileMentionMessage,
47
47
  type HookMessage,
48
48
  type PythonExecutionMessage,
49
+ sanitizeRehydratedOpenAIResponsesAssistantMessage,
49
50
  } from "./messages";
50
51
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
51
52
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
@@ -1374,6 +1375,15 @@ export async function resolveResumableSession(
1374
1375
 
1375
1376
  return { session: globalMatch, scope: "global" };
1376
1377
  }
1378
+ interface SessionManagerStateSnapshot {
1379
+ sessionId: string;
1380
+ sessionName: string | undefined;
1381
+ sessionFile: string | undefined;
1382
+ flushed: boolean;
1383
+ needsFullRewriteOnNextPersist: boolean;
1384
+ fileEntries: FileEntry[];
1385
+ }
1386
+
1377
1387
  export class SessionManager {
1378
1388
  #sessionId: string = "";
1379
1389
  #sessionName: string | undefined;
@@ -1419,6 +1429,39 @@ export class SessionManager {
1419
1429
  return this.#blobStore.put(data);
1420
1430
  }
1421
1431
 
1432
+ captureState(): SessionManagerStateSnapshot {
1433
+ return {
1434
+ sessionId: this.#sessionId,
1435
+ sessionName: this.#sessionName,
1436
+ sessionFile: this.#sessionFile,
1437
+ flushed: this.#flushed,
1438
+ needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
1439
+ // Snapshot entry objects by reference: switch/reload replaces the active entry array,
1440
+ // so rollback does not need structured cloning of extension/custom details.
1441
+ fileEntries: [...this.#fileEntries],
1442
+ };
1443
+ }
1444
+
1445
+ restoreState(snapshot: SessionManagerStateSnapshot): void {
1446
+ this.#sessionId = snapshot.sessionId;
1447
+ this.#sessionName = snapshot.sessionName;
1448
+ this.#sessionFile = snapshot.sessionFile;
1449
+ this.#flushed = snapshot.flushed;
1450
+ this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
1451
+ this.#fileEntries = [...snapshot.fileEntries];
1452
+ this.#persistWriter = undefined;
1453
+ this.#persistWriterPath = undefined;
1454
+ this.#persistChain = Promise.resolve();
1455
+ this.#persistError = undefined;
1456
+ this.#persistErrorReported = false;
1457
+ this.#artifactManager = null;
1458
+ this.#artifactManagerSessionFile = null;
1459
+ this.#buildIndex();
1460
+ if (this.#sessionFile) {
1461
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
1462
+ }
1463
+ }
1464
+
1422
1465
  /** Initialize with a specific session file (used by factory methods) */
1423
1466
  async #initSessionFile(sessionFile: string): Promise<void> {
1424
1467
  await this.setSessionFile(sessionFile);
@@ -1445,6 +1488,7 @@ export class SessionManager {
1445
1488
  this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1446
1489
 
1447
1490
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
1491
+ this.sanitizeLoadedOpenAIResponsesReplayMetadata();
1448
1492
 
1449
1493
  this.#buildIndex();
1450
1494
  this.#flushed = true;
@@ -2300,6 +2344,26 @@ export class SessionManager {
2300
2344
  return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
2301
2345
  }
2302
2346
 
2347
+ /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
2348
+ sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
2349
+ let didSanitize = false;
2350
+ for (const entry of this.#fileEntries) {
2351
+ if (entry.type !== "message" || entry.message.role !== "assistant") {
2352
+ continue;
2353
+ }
2354
+
2355
+ const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
2356
+ if (sanitizedMessage === entry.message) {
2357
+ continue;
2358
+ }
2359
+
2360
+ entry.message = sanitizedMessage;
2361
+ didSanitize = true;
2362
+ }
2363
+
2364
+ return didSanitize;
2365
+ }
2366
+
2303
2367
  /**
2304
2368
  * Get session header.
2305
2369
  */
@@ -2548,6 +2612,7 @@ export class SessionManager {
2548
2612
  newHeader.title = sourceHeader?.title;
2549
2613
  manager.#fileEntries = [newHeader, ...historyEntries];
2550
2614
  manager.#sessionName = newHeader.title;
2615
+ manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
2551
2616
  manager.#buildIndex();
2552
2617
  await manager.#rewriteFile();
2553
2618
  return manager;