@oh-my-pi/pi-coding-agent 13.15.2 → 13.15.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.15.3] - 2026-03-26
6
+
7
+ ### Added
8
+
9
+ - Added configurable `app.model.selectTemporary` keybinding for temporary model selection.
10
+
5
11
  ## [13.15.0] - 2026-03-23
6
12
  ### Breaking Changes
7
13
 
@@ -142,6 +148,10 @@
142
148
  - Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
143
149
  - Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
144
150
 
151
+
152
+ ### Fixed
153
+
154
+ - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505))
145
155
  ## [13.14.0] - 2026-03-20
146
156
 
147
157
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.15.2",
4
+ "version": "13.15.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -42,12 +42,12 @@
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "0.16.1",
44
44
  "@mozilla/readability": "^0.6",
45
- "@oh-my-pi/omp-stats": "13.15.2",
46
- "@oh-my-pi/pi-agent-core": "13.15.2",
47
- "@oh-my-pi/pi-ai": "13.15.2",
48
- "@oh-my-pi/pi-natives": "13.15.2",
49
- "@oh-my-pi/pi-tui": "13.15.2",
50
- "@oh-my-pi/pi-utils": "13.15.2",
45
+ "@oh-my-pi/omp-stats": "13.15.3",
46
+ "@oh-my-pi/pi-agent-core": "13.15.3",
47
+ "@oh-my-pi/pi-ai": "13.15.3",
48
+ "@oh-my-pi/pi-natives": "13.15.3",
49
+ "@oh-my-pi/pi-tui": "13.15.3",
50
+ "@oh-my-pi/pi-utils": "13.15.3",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
@@ -25,6 +25,7 @@ interface AppKeybindings {
25
25
  "app.model.cycleForward": true;
26
26
  "app.model.cycleBackward": true;
27
27
  "app.model.select": true;
28
+ "app.model.selectTemporary": true;
28
29
  "app.tools.expand": true;
29
30
  "app.editor.external": true;
30
31
  "app.message.followUp": true;
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
95
96
  defaultKeys: "ctrl+l",
96
97
  description: "Select model",
97
98
  },
99
+ "app.model.selectTemporary": {
100
+ defaultKeys: "alt+p",
101
+ description: "Select temporary model for current session",
102
+ },
98
103
  "app.tools.expand": {
99
104
  defaultKeys: "ctrl+o",
100
105
  description: "Expand tools",
@@ -194,6 +199,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
194
199
  cycleModelForward: "app.model.cycleForward",
195
200
  cycleModelBackward: "app.model.cycleBackward",
196
201
  selectModel: "app.model.select",
202
+ selectModelTemporary: "app.model.selectTemporary",
197
203
  togglePlanMode: "app.plan.toggle",
198
204
  historySearch: "app.history.search",
199
205
  expandTools: "app.tools.expand",
@@ -11,6 +11,7 @@ type ConfigurableEditorAction = Extract<
11
11
  | "app.model.cycleForward"
12
12
  | "app.model.cycleBackward"
13
13
  | "app.model.select"
14
+ | "app.model.selectTemporary"
14
15
  | "app.tools.expand"
15
16
  | "app.thinking.toggle"
16
17
  | "app.editor.external"
@@ -29,6 +30,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
29
30
  "app.model.cycleForward": ["ctrl+p"],
30
31
  "app.model.cycleBackward": ["shift+ctrl+p"],
31
32
  "app.model.select": ["ctrl+l"],
33
+ "app.model.selectTemporary": ["alt+p"],
32
34
  "app.tools.expand": ["ctrl+o"],
33
35
  "app.thinking.toggle": ["ctrl+t"],
34
36
  "app.editor.external": ["ctrl+g"],
@@ -56,7 +58,7 @@ export class CustomEditor extends Editor {
56
58
  onHistorySearch?: () => void;
57
59
  onSuspend?: () => void;
58
60
  onShowHotkeys?: () => void;
59
- onQuickSelectModel?: () => void;
61
+ onSelectModelTemporary?: () => void;
60
62
  /** Called when the configured copy-prompt shortcut is pressed. */
61
63
  onCopyPrompt?: () => void;
62
64
  /** Called when the configured image-paste shortcut is pressed. */
@@ -126,9 +128,9 @@ export class CustomEditor extends Editor {
126
128
  return;
127
129
  }
128
130
 
129
- // Intercept Alt+P for quick model switching
130
- if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
131
- this.onQuickSelectModel();
131
+ // Intercept configured temporary model selector shortcut
132
+ if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
133
+ this.onSelectModelTemporary();
132
134
  return;
133
135
  }
134
136
 
@@ -11,6 +11,7 @@ import type { AgentSessionEvent } from "../../session/agent-session";
11
11
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
12
12
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
13
13
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
14
+ import { ensureSupportedImageInput } from "../../utils/image-input";
14
15
  import { resizeImage } from "../../utils/image-resize";
15
16
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
16
17
 
@@ -95,7 +96,11 @@ export class InputController {
95
96
  this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
96
97
  this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
97
98
  this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
98
- this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
99
+ this.ctx.editor.setActionKeys(
100
+ "app.model.selectTemporary",
101
+ this.ctx.keybindings.getKeys("app.model.selectTemporary"),
102
+ );
103
+ this.ctx.editor.onSelectModelTemporary = () => this.ctx.showModelSelector({ temporaryOnly: true });
99
104
 
100
105
  // Global debug handler on TUI (works regardless of focus)
101
106
  this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
@@ -498,17 +503,25 @@ export class InputController {
498
503
  const image = await readImageFromClipboard();
499
504
  if (image) {
500
505
  const base64Data = image.data.toBase64();
501
- let imageData = { data: base64Data, mimeType: image.mimeType };
506
+ let imageData = await ensureSupportedImageInput({
507
+ type: "image",
508
+ data: base64Data,
509
+ mimeType: image.mimeType,
510
+ });
511
+ if (!imageData) {
512
+ this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
513
+ return false;
514
+ }
502
515
  if (settings.get("images.autoResize")) {
503
516
  try {
504
517
  const resized = await resizeImage({
505
518
  type: "image",
506
- data: base64Data,
507
- mimeType: image.mimeType,
519
+ data: imageData.data,
520
+ mimeType: imageData.mimeType,
508
521
  });
509
- imageData = { data: resized.data, mimeType: resized.mimeType };
522
+ imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
510
523
  } catch {
511
- imageData = { data: base64Data, mimeType: image.mimeType };
524
+ // Keep the normalized image when resize fails.
512
525
  }
513
526
  }
514
527
 
@@ -40,7 +40,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
40
40
  `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
41
41
  `| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
42
42
  `| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
43
- "| `Alt+P` | Select model (temporary) |",
43
+ `| \`${appKey(bindings, "app.model.selectTemporary")}\` | Select model (temporary) |`,
44
44
  `| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
45
45
  `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
46
46
  `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
@@ -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
  // =========================================================================
@@ -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);
@@ -4085,22 +4099,174 @@ export class AgentSession {
4085
4099
  }
4086
4100
 
4087
4101
  #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
4088
- if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
4102
+ const providerKeys = new Set<string>();
4103
+ if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
4104
+ providerKeys.add("openai-codex-responses");
4105
+ }
4106
+ if (currentModel.api === "openai-responses") {
4107
+ providerKeys.add(`openai-responses:${currentModel.provider}`);
4108
+ }
4109
+ if (nextModel.api === "openai-responses") {
4110
+ providerKeys.add(`openai-responses:${nextModel.provider}`);
4111
+ }
4089
4112
 
4090
- const providerKey = "openai-codex-responses";
4091
- const state = this.#providerSessionState.get(providerKey);
4092
- if (!state) return;
4113
+ for (const providerKey of providerKeys) {
4114
+ const state = this.#providerSessionState.get(providerKey);
4115
+ if (!state) continue;
4093
4116
 
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
- });
4117
+ try {
4118
+ state.close();
4119
+ } catch (error) {
4120
+ logger.warn("Failed to close provider session state during model switch", {
4121
+ providerKey,
4122
+ error: String(error),
4123
+ });
4124
+ }
4125
+
4126
+ this.#providerSessionState.delete(providerKey);
4127
+ }
4128
+ }
4129
+
4130
+ #normalizeProviderReplayValue(value: unknown): unknown {
4131
+ if (Array.isArray(value)) {
4132
+ return value.map(item => this.#normalizeProviderReplayValue(item));
4133
+ }
4134
+ if (value && typeof value === "object") {
4135
+ return Object.fromEntries(
4136
+ Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]),
4137
+ );
4138
+ }
4139
+ return value;
4140
+ }
4141
+
4142
+ #normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
4143
+ switch (message.role) {
4144
+ case "user":
4145
+ case "developer":
4146
+ return {
4147
+ role: message.role,
4148
+ content: this.#normalizeProviderReplayValue(message.content),
4149
+ providerPayload: message.providerPayload,
4150
+ };
4151
+ case "assistant": {
4152
+ const isResponsesFamilyMessage =
4153
+ message.api === "openai-responses" || message.api === "openai-codex-responses";
4154
+ return {
4155
+ role: message.role,
4156
+ content:
4157
+ isResponsesFamilyMessage && Array.isArray(message.content)
4158
+ ? message.content.flatMap(block => {
4159
+ if (block.type === "thinking") {
4160
+ return [];
4161
+ }
4162
+ if (block.type === "toolCall") {
4163
+ return [
4164
+ {
4165
+ type: block.type,
4166
+ id: block.id,
4167
+ name: block.name,
4168
+ arguments: block.arguments,
4169
+ },
4170
+ ];
4171
+ }
4172
+ if (block.type === "text") {
4173
+ return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
4174
+ }
4175
+ return [this.#normalizeProviderReplayValue(block)];
4176
+ })
4177
+ : this.#normalizeProviderReplayValue(message.content),
4178
+ api: message.api,
4179
+ provider: message.provider,
4180
+ model: message.model,
4181
+ stopReason: message.stopReason,
4182
+ errorMessage: message.errorMessage,
4183
+ providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
4184
+ };
4185
+ }
4186
+ case "toolResult":
4187
+ return {
4188
+ role: message.role,
4189
+ toolName: message.toolName,
4190
+ toolCallId: message.toolCallId,
4191
+ isError: message.isError,
4192
+ content: this.#normalizeProviderReplayValue(message.content),
4193
+ };
4194
+ case "bashExecution":
4195
+ return {
4196
+ role: message.role,
4197
+ command: message.command,
4198
+ output: message.output,
4199
+ exitCode: message.exitCode,
4200
+ cancelled: message.cancelled,
4201
+ meta: message.meta
4202
+ ? {
4203
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4204
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4205
+ diagnostics: message.meta.diagnostics
4206
+ ? this.#normalizeProviderReplayValue({
4207
+ summary: message.meta.diagnostics.summary,
4208
+ messages: message.meta.diagnostics.messages,
4209
+ })
4210
+ : undefined,
4211
+ }
4212
+ : undefined,
4213
+ excludeFromContext: message.excludeFromContext,
4214
+ };
4215
+ case "pythonExecution":
4216
+ return {
4217
+ role: message.role,
4218
+ code: message.code,
4219
+ output: message.output,
4220
+ exitCode: message.exitCode,
4221
+ cancelled: message.cancelled,
4222
+ meta: message.meta
4223
+ ? {
4224
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4225
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4226
+ diagnostics: message.meta.diagnostics
4227
+ ? this.#normalizeProviderReplayValue({
4228
+ summary: message.meta.diagnostics.summary,
4229
+ messages: message.meta.diagnostics.messages,
4230
+ })
4231
+ : undefined,
4232
+ }
4233
+ : undefined,
4234
+ excludeFromContext: message.excludeFromContext,
4235
+ };
4236
+ case "custom":
4237
+ case "hookMessage":
4238
+ return {
4239
+ role: message.role,
4240
+ customType: message.customType,
4241
+ content: this.#normalizeProviderReplayValue(message.content),
4242
+ };
4243
+ case "branchSummary":
4244
+ return { role: message.role, summary: message.summary };
4245
+ case "compactionSummary":
4246
+ return {
4247
+ role: message.role,
4248
+ summary: message.summary,
4249
+ providerPayload: message.providerPayload,
4250
+ };
4251
+ case "fileMention":
4252
+ return {
4253
+ role: message.role,
4254
+ files: message.files.map(file => ({
4255
+ path: file.path,
4256
+ content: file.content,
4257
+ image: file.image,
4258
+ })),
4259
+ };
4260
+ default:
4261
+ return this.#normalizeProviderReplayValue(message);
4101
4262
  }
4263
+ }
4102
4264
 
4103
- this.#providerSessionState.delete(providerKey);
4265
+ #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
4266
+ return (
4267
+ JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
4268
+ JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
4269
+ );
4104
4270
  }
4105
4271
 
4106
4272
  #getModelKey(model: Model): string {
@@ -5035,7 +5201,9 @@ export class AgentSession {
5035
5201
  */
5036
5202
  async switchSession(sessionPath: string): Promise<boolean> {
5037
5203
  const previousSessionFile = this.sessionManager.getSessionFile();
5038
-
5204
+ const switchingToDifferentSession = previousSessionFile
5205
+ ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
5206
+ : true;
5039
5207
  // Emit session_before_switch event (can be cancelled)
5040
5208
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
5041
5209
  const result = (await this.#extensionRunner.emit({
@@ -5051,68 +5219,149 @@ export class AgentSession {
5051
5219
 
5052
5220
  this.#disconnectFromAgent();
5053
5221
  await this.abort();
5222
+
5223
+ // Flush pending writes before switching so restore snapshots reflect committed state.
5224
+ await this.sessionManager.flush();
5225
+ const previousSessionState = this.sessionManager.captureState();
5226
+ const previousSessionContext = this.sessionManager.buildSessionContext();
5227
+ // switchSession replaces these arrays wholesale during load/rollback, so retaining
5228
+ // the existing message objects is sufficient and avoids structured-clone failures for
5229
+ // extension/custom metadata that is valid to persist but not cloneable.
5230
+ const previousAgentMessages = [...this.agent.state.messages];
5231
+ const previousSteeringMessages = [...this.#steeringMessages];
5232
+ const previousFollowUpMessages = [...this.#followUpMessages];
5233
+ const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
5234
+ const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
5235
+ const previousModel = this.model;
5236
+ const previousThinkingLevel = this.#thinkingLevel;
5237
+ const previousServiceTier = this.agent.serviceTier;
5238
+ const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
5239
+ const previousTools = [...this.agent.state.tools];
5240
+ const previousBaseSystemPrompt = this.#baseSystemPrompt;
5241
+ const previousSystemPrompt = this.agent.state.systemPrompt;
5242
+ const previousFallbackSelectedMCPToolNames = previousSessionFile
5243
+ ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
5244
+ : undefined;
5245
+
5054
5246
  this.#steeringMessages = [];
5055
5247
  this.#followUpMessages = [];
5056
5248
  this.#pendingNextTurnMessages = [];
5057
5249
  this.#scheduledHiddenNextTurnGeneration = undefined;
5058
5250
 
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 });
5251
+ try {
5252
+ await this.sessionManager.setSessionFile(sessionPath);
5253
+ this.agent.sessionId = this.sessionManager.getSessionId();
5070
5254
 
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
- }
5255
+ const sessionContext = this.sessionManager.buildSessionContext();
5256
+ const didReloadConversationChange =
5257
+ !switchingToDifferentSession &&
5258
+ this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
5259
+ const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
5260
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
5079
5261
 
5080
- this.agent.replaceMessages(sessionContext.messages);
5081
- this.#syncTodoPhasesFromBranch();
5262
+ // Emit session_switch event to hooks
5263
+ if (this.#extensionRunner) {
5264
+ await this.#extensionRunner.emit({
5265
+ type: "session_switch",
5266
+ reason: "resume",
5267
+ previousSessionFile,
5268
+ });
5269
+ }
5082
5270
 
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);
5271
+ this.agent.replaceMessages(sessionContext.messages);
5272
+ this.#syncTodoPhasesFromBranch();
5273
+ if (switchingToDifferentSession) {
5274
+ this.#closeAllProviderSessions("session switch");
5275
+ } else if (didReloadConversationChange) {
5276
+ this.#closeAllProviderSessions("session reload");
5277
+ }
5278
+
5279
+ // Restore model if saved
5280
+ const defaultModelStr = sessionContext.models.default;
5281
+ if (defaultModelStr) {
5282
+ const slashIdx = defaultModelStr.indexOf("/");
5283
+ if (slashIdx > 0) {
5284
+ const provider = defaultModelStr.slice(0, slashIdx);
5285
+ const modelId = defaultModelStr.slice(slashIdx + 1);
5286
+ const availableModels = this.#modelRegistry.getAvailable();
5287
+ const match = availableModels.find(m => m.provider === provider && m.id === modelId);
5288
+ if (match) {
5289
+ const currentModel = this.model;
5290
+ const shouldResetProviderState =
5291
+ switchingToDifferentSession ||
5292
+ (currentModel !== undefined &&
5293
+ (currentModel.provider !== match.provider ||
5294
+ currentModel.id !== match.id ||
5295
+ currentModel.api !== match.api));
5296
+ if (shouldResetProviderState) {
5297
+ this.#setModelWithProviderSessionReset(match);
5298
+ } else {
5299
+ this.agent.setModel(match);
5300
+ }
5301
+ }
5094
5302
  }
5095
5303
  }
5096
- }
5097
5304
 
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;
5305
+ const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5306
+ const hasServiceTierEntry = this.sessionManager
5307
+ .getBranch()
5308
+ .some(entry => entry.type === "service_tier_change");
5309
+ const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5310
+ const configuredServiceTier = this.settings.get("serviceTier");
5311
+ const nextThinkingLevel = resolveThinkingLevelForModel(
5312
+ this.model,
5313
+ hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
5314
+ );
5315
+ this.#thinkingLevel = nextThinkingLevel;
5316
+ this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
5317
+ this.agent.serviceTier = hasServiceTierEntry
5318
+ ? sessionContext.serviceTier
5319
+ : configuredServiceTier === "none"
5320
+ ? undefined
5321
+ : configuredServiceTier;
5113
5322
 
5114
- this.#reconnectToAgent();
5115
- return true;
5323
+ this.#reconnectToAgent();
5324
+ return true;
5325
+ } catch (error) {
5326
+ this.sessionManager.restoreState(previousSessionState);
5327
+ this.agent.sessionId = previousSessionState.sessionId;
5328
+ let restoreMcpError: unknown;
5329
+ try {
5330
+ await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
5331
+ fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
5332
+ });
5333
+ } catch (mcpError) {
5334
+ restoreMcpError = mcpError;
5335
+ logger.warn("Failed to restore MCP selections after switch error", {
5336
+ previousSessionFile,
5337
+ targetSessionFile: sessionPath,
5338
+ error: String(mcpError),
5339
+ });
5340
+ this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
5341
+ this.agent.setTools(previousTools);
5342
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5343
+ this.agent.setSystemPrompt(previousSystemPrompt);
5344
+ }
5345
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5346
+ this.agent.setSystemPrompt(previousSystemPrompt);
5347
+ this.agent.replaceMessages(previousAgentMessages);
5348
+ this.#steeringMessages = previousSteeringMessages;
5349
+ this.#followUpMessages = previousFollowUpMessages;
5350
+ this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
5351
+ this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
5352
+ if (previousModel) {
5353
+ this.agent.setModel(previousModel);
5354
+ }
5355
+ this.#thinkingLevel = previousThinkingLevel;
5356
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
5357
+ this.agent.serviceTier = previousServiceTier;
5358
+ this.#syncTodoPhasesFromBranch();
5359
+ this.#reconnectToAgent();
5360
+ if (restoreMcpError) {
5361
+ throw restoreMcpError;
5362
+ }
5363
+ throw error;
5364
+ }
5116
5365
  }
5117
5366
 
5118
5367
  /**
@@ -5124,7 +5373,10 @@ export class AgentSession {
5124
5373
  * - selectedText: The text of the selected user message (for editor pre-fill)
5125
5374
  * - cancelled: True if a hook cancelled the branch
5126
5375
  */
5127
- async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
5376
+ async branch(entryId: string): Promise<{
5377
+ selectedText: string;
5378
+ cancelled: boolean;
5379
+ }> {
5128
5380
  const previousSessionFile = this.sessionFile;
5129
5381
  const selectedEntry = this.sessionManager.getEntry(entryId);
5130
5382
 
@@ -5202,7 +5454,12 @@ export class AgentSession {
5202
5454
  async navigateTree(
5203
5455
  targetId: string,
5204
5456
  options: { summarize?: boolean; customInstructions?: string } = {},
5205
- ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
5457
+ ): Promise<{
5458
+ editorText?: string;
5459
+ cancelled: boolean;
5460
+ aborted?: boolean;
5461
+ summaryEntry?: BranchSummaryEntry;
5462
+ }> {
5206
5463
  const oldLeafId = this.sessionManager.getLeafId();
5207
5464
 
5208
5465
  // 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;
@@ -1,12 +1,14 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
3
  import { formatBytes } from "@oh-my-pi/pi-utils";
3
4
  import { resolveReadPath } from "../tools/path-utils";
5
+ import { convertToPng } from "./image-convert";
4
6
  import { formatDimensionNote, resizeImage } from "./image-resize";
5
7
  import { detectSupportedImageMimeTypeFromFile } from "./mime";
6
8
 
7
9
  export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
8
10
  const MAX_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
9
-
11
+ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
10
12
  export interface ImageMetadata {
11
13
  mimeType: string;
12
14
  bytes: number;
@@ -25,6 +27,14 @@ export interface LoadedImageInput {
25
27
  bytes: number;
26
28
  }
27
29
 
30
+ export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
31
+ if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
32
+ return image;
33
+ }
34
+ const converted = await convertToPng(image.data, image.mimeType);
35
+ return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
36
+ }
37
+
28
38
  export interface ReadImageMetadataOptions {
29
39
  path: string;
30
40
  cwd: string;
@@ -6,7 +6,7 @@
6
6
  * Returns synthesized answers with web search sources.
7
7
  */
8
8
  import * as os from "node:os";
9
- import { getAgentDbPath, readSseJson } from "@oh-my-pi/pi-utils";
9
+ import { $env, getAgentDbPath, readSseJson } from "@oh-my-pi/pi-utils";
10
10
  import packageJson from "../../../../package.json" with { type: "json" };
11
11
  import { AgentStorage } from "../../../session/agent-storage";
12
12
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -21,6 +21,11 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
21
21
  const DEFAULT_INSTRUCTIONS =
22
22
  "You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
23
23
 
24
+ function getModel(): string {
25
+ const configuredModel = $env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
26
+ return configuredModel ? configuredModel : DEFAULT_MODEL;
27
+ }
28
+
24
29
  export interface CodexSearchParams {
25
30
  signal?: AbortSignal;
26
31
  query: string;
@@ -188,8 +193,10 @@ async function callCodexSearch(
188
193
  const url = `${CODEX_BASE_URL}${CODEX_RESPONSES_PATH}`;
189
194
  const headers = buildCodexHeaders(auth.accessToken, auth.accountId);
190
195
 
196
+ const requestedModel = getModel();
197
+
191
198
  const body: Record<string, unknown> = {
192
- model: DEFAULT_MODEL,
199
+ model: requestedModel,
193
200
  stream: true,
194
201
  store: false,
195
202
  input: [
@@ -226,7 +233,7 @@ async function callCodexSearch(
226
233
  // Parse SSE stream
227
234
  const answerParts: string[] = [];
228
235
  const sources: SearchSource[] = [];
229
- let model = DEFAULT_MODEL;
236
+ let model = requestedModel;
230
237
  let requestId = "";
231
238
  let usage: { inputTokens: number; outputTokens: number; totalTokens: number } | undefined;
232
239