Repository: can1357/oh-my-pi (2026-03-20T09:08:07Z)

Issue:
bug(coding-agent): resumed GitHub Copilot sessions can fail with 401 input item does not belong to this connection
### Description
After exiting omp, resuming a GitHub Copilot conversation, and sending the next follow-up, the first resumed assistant turn can fail with `401 input item does not belong to this connection`.

The failure is caused by replaying persisted OpenAI Responses native history across a fresh process/connection. On resume, omp rebuilds message history from the session file, but the in-memory provider session state starts empty. The next request can still include stale native Responses items, including opaque reasoning items with `encrypted_content`, from the previous connection.

### Steps to Reproduce
1. Start an omp session using GitHub Copilot on an OpenAI Responses model such as `gpt-5.4`.
2. Have a few turns that produce native Responses history, including reasoning content.
3. Exit omp.
4. Resume the saved session.
5. Send a normal follow-up message.
6. Observe the first resumed assistant turn fail.

### Expected Behavior
Resumed sessions should continue normally. omp should avoid replaying connection-bound native Responses items after process restart and should fall back to rebuilt message history when the provider session is fresh.

### Error Output
```shell
Error: 401 input item does not belong to this connection
```

### Platform
macOS

### omp version
13.13.2

### Bun version
1.3.10

### Provider
GitHub Copilot

### Area
Session / Compaction

### Additional context
The relevant design flaw is same-provider `openaiResponsesHistory` replay during resumed-session follow-up requests. Cross-process resume starts with a fresh in-memory `providerSessionState`, but the rebuilt request can still replay provider-native history captured from the previous run. That is especially risky for opaque `reasoning` items with `encrypted_content`, which appear to be connection-bound on the backend.


Discussion:

Patch:
diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md
index 3ac3b56e70..4be57a4cc7 100644
--- a/packages/ai/CHANGELOG.md
+++ b/packages/ai/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Fixed
+
+- Fixed resumed OpenAI Responses sessions to avoid replaying stale same-provider native history on the first follow-up after process restart ([#488](https://github.com/can1357/oh-my-pi/issues/488))
+
 ### Added
 
 - Added bundled GPT-5.4 mini model metadata for OpenAI, OpenAI Codex, and GitHub Copilot, including low-to-xhigh thinking support and GitHub Copilot premium multiplier metadata
diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts
index 95682ef759..90f66e8ae8 100644
--- a/packages/ai/src/providers/openai-responses.ts
+++ b/packages/ai/src/providers/openai-responses.ts
@@ -14,6 +14,7 @@ import {
 	isSpecialServiceTier,
 	type MessageAttribution,
 	type Model,
+	type ProviderSessionState,
 	type ServiceTier,
 	type StreamFunction,
 	type StreamOptions,
@@ -74,6 +75,45 @@ export interface OpenAIResponsesOptions extends StreamOptions {
 	strictResponsesPairing?: boolean;
 }
 
+const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
+
+interface OpenAIResponsesProviderSessionState extends ProviderSessionState {
+	nativeHistoryReplayWarmed: boolean;
+}
+
+function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
+	const state: OpenAIResponsesProviderSessionState = {
+		nativeHistoryReplayWarmed: false,
+		close: () => {
+			state.nativeHistoryReplayWarmed = false;
+		},
+	};
+	return state;
+}
+
+function getOpenAIResponsesProviderSessionStateKey(model: Model<"openai-responses">): string {
+	return `${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`;
+}
+
+function getOpenAIResponsesProviderSessionState(
+	model: Model<"openai-responses">,
+	providerSessionState: Map<string, ProviderSessionState> | undefined,
+): OpenAIResponsesProviderSessionState | undefined {
+	if (!providerSessionState) return undefined;
+	const key = getOpenAIResponsesProviderSessionStateKey(model);
+	const existing = providerSessionState.get(key) as OpenAIResponsesProviderSessionState | undefined;
+	if (existing) return existing;
+	const created = createOpenAIResponsesProviderSessionState();
+	providerSessionState.set(key, created);
+	return created;
+}
+
+function canReplayOpenAIResponsesNativeHistory(
+	providerSessionState: OpenAIResponsesProviderSessionState | undefined,
+): boolean {
+	return providerSessionState?.nativeHistoryReplayWarmed ?? true;
+}
+
 type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
 	top_p?: number;
 	top_k?: number;
@@ -126,7 +166,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
 				options?.headers,
 				options?.initiatorOverride,
 			);
-			const { params } = buildParams(model, context, options);
+			const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
+			const { params } = buildParams(model, context, options, providerSessionState);
 			const requestAbortController = new AbortController();
 			const requestSignal = options?.signal
 				? AbortSignal.any([options.signal, requestAbortController.signal])
@@ -174,6 +215,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
 			}
 
 			output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
+			if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
 
 			output.duration = Date.now() - startTime;
 			if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -246,12 +288,18 @@ function createClient(
 function buildParams(
 	model: Model<"openai-responses">,
 	context: Context,
-	options?: OpenAIResponsesOptions,
+	options: OpenAIResponsesOptions | undefined,
+	providerSessionState: OpenAIResponsesProviderSessionState | undefined,
 ): { conversationMessages: ResponseInput; params: OpenAIResponsesSamplingParams } {
 	const strictResponsesPairing =
 		options?.strictResponsesPairing ??
 		(isAzureOpenAIBaseUrl(model.baseUrl ?? "") || model.provider === "github-copilot");
-	const conversationMessages = convertConversationMessages(model, context, strictResponsesPairing);
+	const conversationMessages = convertConversationMessages(
+		model,
+		context,
+		strictResponsesPairing,
+		providerSessionState,
+	);
 	const messages: ResponseInput = [...conversationMessages];
 
 	if (context.systemPrompt) {
@@ -354,16 +402,20 @@ function convertConversationMessages(
 	model: Model<"openai-responses">,
 	context: Context,
 	strictResponsesPairing: boolean,
+	providerSessionState: OpenAIResponsesProviderSessionState | undefined,
 ): ResponseInput {
 	const messages: ResponseInput = [];
 	let knownCallIds = new Set<string>();
+	const shouldReplayNativeHistory = canReplayOpenAIResponsesNativeHistory(providerSessionState);
 	const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform);
 
 	let msgIndex = 0;
 	for (const msg of transformedMessages) {
 		if (msg.role === "user" || msg.role === "developer") {
 			const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
-			const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
+			const historyItems = shouldReplayNativeHistory
+				? getOpenAIResponsesHistoryItems(providerPayload, model.provider)
+				: undefined;
 			if (historyItems) {
 				messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems));
 				knownCallIds = collectKnownCallIds(messages);
@@ -375,11 +427,9 @@ function convertConversationMessages(
 			messages.push({ role: "user", content });
 		} else if (msg.role === "assistant") {
 			const assistantMsg = msg as AssistantMessage;
-			const providerPayload = getOpenAIResponsesHistoryPayload(
-				assistantMsg.providerPayload,
-				model.provider,
-				assistantMsg.provider,
-			);
+			const providerPayload = shouldReplayNativeHistory
+				? getOpenAIResponsesHistoryPayload(assistantMsg.providerPayload, model.provider, assistantMsg.provider)
+				: undefined;
 			const historyItems = providerPayload?.items;
 			if (historyItems) {
 				const sanitizedHistoryItems = sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems);
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index b46ddfda32..7bccff114c 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Fixed
+
+- Fixed resumed GitHub Copilot conversations that could fail with `401 input item does not belong to this connection` on the first follow-up after process restart ([#488](https://github.com/can1357/oh-my-pi/issues/488))
+
 ## [13.13.2] - 2026-03-18
 
 ### Added


Test patch:
diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts
index f9c51f42a9..28a7b29737 100644
--- a/packages/ai/test/openai-responses-history-payload.test.ts
+++ b/packages/ai/test/openai-responses-history-payload.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
 import { getBundledModel } from "@oh-my-pi/pi-ai/models";
 import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
 import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses";
-import type { Context, Model } from "@oh-my-pi/pi-ai/types";
+import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
 import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "../src/utils";
 
 function createAbortedSignal(): AbortSignal {
@@ -71,11 +71,61 @@ const codexToCopilotContext: Context = {
 	],
 };
 
-function captureResponsesPayload(model: Model<"openai-responses">, context: Context): Promise<unknown> {
+const resumedSameProviderContext: Context = {
+	messages: [
+		{
+			role: "user",
+			content: "summary that should be preserved",
+			providerPayload: createOpenAIResponsesHistoryPayload("openai", preservedHistoryItems, false),
+			timestamp: Date.now(),
+		},
+		{
+			...makeAssistantMessage([{ type: "reasoning", encrypted_content: "enc_123" }, ...snapshotHistoryItems]),
+			content: [{ type: "text", text: "generic assistant that should be rebuilt" }],
+		},
+		{ role: "user", content: "follow-up user", timestamp: Date.now() },
+	],
+};
+
+const resumedCopilotSameProviderContext: Context = {
+	messages: [
+		{
+			role: "user",
+			content: "summary that should be preserved",
+			providerPayload: createOpenAIResponsesHistoryPayload("github-copilot", preservedHistoryItems, false),
+			timestamp: Date.now(),
+		},
+		{
+			...makeAssistantMessage(
+				[{ type: "reasoning", encrypted_content: "enc_123" }, ...snapshotHistoryItems],
+				false,
+				"github-copilot",
+				"gpt-5.4",
+			),
+			content: [{ type: "text", text: "generic assistant that should be rebuilt" }],
+		},
+		{ role: "user", content: "follow-up user", timestamp: Date.now() },
+	],
+};
+
+function markResponsesProviderSessionStateWarmed(providerSessionState: Map<string, ProviderSessionState>): void {
+	const state = providerSessionState.values().next().value as
+		| (ProviderSessionState & { nativeHistoryReplayWarmed: boolean })
+		| undefined;
+	if (!state) throw new Error("Expected OpenAI Responses provider session state");
+	state.nativeHistoryReplayWarmed = true;
+}
+
+function captureResponsesPayload(
+	model: Model<"openai-responses">,
+	context: Context,
+	providerSessionState?: Map<string, ProviderSessionState>,
+): Promise<unknown> {
 	const { promise, resolve } = Promise.withResolvers<unknown>();
 	streamOpenAIResponses(model, context, {
 		apiKey: "test-key",
 		signal: createAbortedSignal(),
+		providerSessionState,
 		onPayload: payload => resolve(payload),
 	});
 	return promise;
@@ -114,13 +164,13 @@ const incrementalItems2 = [
 function makeAssistantMessage(
 	items: Record<string, unknown>[],
 	incremental = false,
-	provider: "openai" | "openai-codex" = "openai",
-	model = provider === "openai" ? "gpt-5-mini" : "gpt-5.2-codex",
+	provider: "openai" | "openai-codex" | "github-copilot" = "openai",
+	model = provider === "openai-codex" ? "gpt-5.2-codex" : provider === "github-copilot" ? "gpt-5.4" : "gpt-5-mini",
 ) {
 	return {
 		role: "assistant" as const,
 		content: [{ type: "text" as const, text: "ignored" }],
-		api: provider === "openai" ? ("openai-responses" as const) : ("openai-codex-responses" as const),
+		api: provider === "openai-codex" ? ("openai-codex-responses" as const) : ("openai-responses" as const),
 		provider,
 		model,
 		usage: {
@@ -176,6 +226,19 @@ function findResponsesInputItem(input: unknown[] | undefined, type: string): Rec
 	}) as Record<string, unknown> | undefined;
 }
 
+function containsUserInputText(input: unknown[] | undefined, text: string): boolean {
+	return (input ?? []).some(item => {
+		if (!item || typeof item !== "object") return false;
+		const candidate = item as { role?: unknown; content?: unknown };
+		if (candidate.role !== "user" || !Array.isArray(candidate.content)) return false;
+		return candidate.content.some(part => {
+			if (!part || typeof part !== "object") return false;
+			const content = part as { type?: unknown; text?: unknown };
+			return content.type === "input_text" && content.text === text;
+		});
+	});
+}
+
 describe("OpenAI responses history payload", () => {
 	it("inlines preserved replacement history for openai-responses", async () => {
 		const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
@@ -192,6 +255,50 @@ describe("OpenAI responses history payload", () => {
 		]);
 	});
 
+	it("falls back to rebuilt history on resumed same-provider sessions with fresh session state", async () => {
+		const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
+		const providerSessionState = new Map<string, ProviderSessionState>();
+		const payload = (await captureResponsesPayload(model, resumedSameProviderContext, providerSessionState)) as {
+			input?: unknown[];
+		};
+		expect(containsEncryptedReasoning(payload.input)).toBe(false);
+		expect(containsUserInputText(payload.input, "summary that should be preserved")).toBe(true);
+		expect(containsAssistantOutputText(payload.input, "generic assistant that should be rebuilt")).toBe(true);
+		expect(containsAssistantOutputText(payload.input, "Canonical assistant")).toBe(false);
+	});
+
+	it("replays native history after the same-provider session state is warmed", async () => {
+		const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
+		const providerSessionState = new Map<string, ProviderSessionState>();
+		await captureResponsesPayload(model, resumedSameProviderContext, providerSessionState);
+		markResponsesProviderSessionStateWarmed(providerSessionState);
+		const payload = (await captureResponsesPayload(model, resumedSameProviderContext, providerSessionState)) as {
+			input?: unknown[];
+		};
+		expect(payload.input).toEqual([
+			{ type: "reasoning", encrypted_content: "enc_123" },
+			...snapshotHistoryItems,
+			{ role: "user", content: [{ type: "input_text", text: "follow-up user" }] },
+		]);
+	});
+
+	it("does not warm GitHub Copilot replay when only OpenAI replay state is warmed", async () => {
+		const openAiModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
+		const copilotModel = getBundledModel("github-copilot", "gpt-5.4") as Model<"openai-responses">;
+		const providerSessionState = new Map<string, ProviderSessionState>();
+		await captureResponsesPayload(openAiModel, resumedSameProviderContext, providerSessionState);
+		markResponsesProviderSessionStateWarmed(providerSessionState);
+		const payload = (await captureResponsesPayload(
+			copilotModel,
+			resumedCopilotSameProviderContext,
+			providerSessionState,
+		)) as { input?: unknown[] };
+		expect(containsEncryptedReasoning(payload.input)).toBe(false);
+		expect(containsUserInputText(payload.input, "summary that should be preserved")).toBe(true);
+		expect(containsAssistantOutputText(payload.input, "generic assistant that should be rebuilt")).toBe(true);
+		expect(containsAssistantOutputText(payload.input, "Canonical assistant")).toBe(false);
+	});
+
 	it("prefers assistant native history snapshots for openai-codex-responses", async () => {
 		const model = getBundledModel("openai-codex", "gpt-5.2-codex") as Model<"openai-codex-responses">;
 		const payload = (await captureCodexPayload(model, codexAssistantSnapshotContext)) as { input?: unknown[] };