fix: handle ambiguous prompt transport failures
This commit is contained in:
@@ -22,13 +22,16 @@ const SyncOptimisticBridge: React.FC = () => {
|
|||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
const addRef = React.useRef(sync.optimistic.add);
|
const addRef = React.useRef(sync.optimistic.add);
|
||||||
const removeRef = React.useRef(sync.optimistic.remove);
|
const removeRef = React.useRef(sync.optimistic.remove);
|
||||||
|
const confirmRef = React.useRef(sync.optimistic.confirm);
|
||||||
addRef.current = sync.optimistic.add;
|
addRef.current = sync.optimistic.add;
|
||||||
removeRef.current = sync.optimistic.remove;
|
removeRef.current = sync.optimistic.remove;
|
||||||
|
confirmRef.current = sync.optimistic.confirm;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setOptimisticRefs(
|
setOptimisticRefs(
|
||||||
(input) => addRef.current(input),
|
(input) => addRef.current(input),
|
||||||
(input) => removeRef.current(input),
|
(input) => removeRef.current(input),
|
||||||
|
(input) => confirmRef.current(input),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -154,9 +154,6 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!payload.primaryText && payload.primaryAttachments.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use send config captured at queue time; fall back to current config
|
// Use send config captured at queue time; fall back to current config
|
||||||
const captured = payload.sendConfig;
|
const captured = payload.sendConfig;
|
||||||
@@ -176,9 +173,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
agent: resolved.agent,
|
agent: resolved.agent,
|
||||||
variant: resolved.variant,
|
variant: resolved.variant,
|
||||||
});
|
});
|
||||||
|
useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId);
|
||||||
const removeFromQueue = useMessageQueueStore.getState().removeFromQueue;
|
|
||||||
removeFromQueue(sessionId, payload.queuedMessageId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[queue] queued auto-send failed:', error);
|
console.warn('[queue] queued auto-send failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
type ConfigResponse = { data: Record<string, unknown> };
|
type ConfigResponse = { data: Record<string, unknown> };
|
||||||
|
|
||||||
@@ -6,6 +6,15 @@ type ConfigResponse = { data: Record<string, unknown> };
|
|||||||
|
|
||||||
const configResolvers: Array<(response: ConfigResponse) => void> = [];
|
const configResolvers: Array<(response: ConfigResponse) => void> = [];
|
||||||
let configCalls = 0;
|
let configCalls = 0;
|
||||||
|
const promptAsyncCalls: unknown[][] = [];
|
||||||
|
const promptAsyncResults: Array<unknown> = [];
|
||||||
|
|
||||||
|
const promptAsyncMock = mock(async (...args: unknown[]) => {
|
||||||
|
promptAsyncCalls.push(args);
|
||||||
|
const next = promptAsyncResults.shift();
|
||||||
|
if (next instanceof Error) throw next;
|
||||||
|
return next ?? { response: new Response(null, { status: 200 }) };
|
||||||
|
});
|
||||||
|
|
||||||
mock.module('@opencode-ai/sdk/v2', () => ({
|
mock.module('@opencode-ai/sdk/v2', () => ({
|
||||||
createOpencodeClient: mock(() => ({
|
createOpencodeClient: mock(() => ({
|
||||||
@@ -17,6 +26,9 @@ mock.module('@opencode-ai/sdk/v2', () => ({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
promptAsync: promptAsyncMock,
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -47,6 +59,11 @@ mock.module('@/lib/startupTrace', () => ({
|
|||||||
|
|
||||||
const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`);
|
const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
promptAsyncCalls.length = 0;
|
||||||
|
promptAsyncResults.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
describe('opencodeClient getConfig cache', () => {
|
describe('opencodeClient getConfig cache', () => {
|
||||||
test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => {
|
test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => {
|
||||||
const first = opencodeClient.getConfig('/workspace/project');
|
const first = opencodeClient.getConfig('/workspace/project');
|
||||||
@@ -72,3 +89,54 @@ describe('opencodeClient getConfig cache', () => {
|
|||||||
expect(configCalls).toBe(2);
|
expect(configCalls).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('opencodeClient prompt retry behavior', () => {
|
||||||
|
const sendPrompt = (providerID = 'anthropic') => opencodeClient.sendMessage({
|
||||||
|
id: 'ses_1',
|
||||||
|
providerID,
|
||||||
|
modelID: 'claude-sonnet',
|
||||||
|
text: 'hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not retry 504 prompt responses because the POST may already be accepted', async () => {
|
||||||
|
promptAsyncResults.push({ response: new Response('gateway timeout', { status: 504 }) });
|
||||||
|
|
||||||
|
let error: unknown = null;
|
||||||
|
try {
|
||||||
|
await sendPrompt('anthropic-504');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(promptAsyncCalls.length).toBe(1);
|
||||||
|
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (504)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not retry transport failures because the tunnel may have lost only the response', async () => {
|
||||||
|
promptAsyncResults.push(new TypeError('Failed to fetch'));
|
||||||
|
|
||||||
|
let error: unknown = null;
|
||||||
|
try {
|
||||||
|
await sendPrompt('anthropic-network');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(promptAsyncCalls.length).toBe(1);
|
||||||
|
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not retry 503 prompt responses because proxy errors can be ambiguous too', async () => {
|
||||||
|
promptAsyncResults.push({ response: new Response('starting', { status: 503 }) });
|
||||||
|
|
||||||
|
let error: unknown = null;
|
||||||
|
try {
|
||||||
|
await sendPrompt('anthropic-503');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(promptAsyncCalls.length).toBe(1);
|
||||||
|
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
assertProviderCircuitClosed,
|
assertProviderCircuitClosed,
|
||||||
recordProviderSuccess,
|
recordProviderSuccess,
|
||||||
recordProviderError,
|
recordProviderError,
|
||||||
shouldRetry,
|
|
||||||
getRetryDelayMs,
|
|
||||||
} from "./provider-tracker";
|
} from "./provider-tracker";
|
||||||
|
|
||||||
// Use relative path by default (works with both dev and nginx proxy server)
|
// Use relative path by default (works with both dev and nginx proxy server)
|
||||||
@@ -120,12 +118,6 @@ const ascendingId = (prefix: "msg"): string => {
|
|||||||
return `${prefix}_${hex}${randomBase62(ID_RANDOM_LENGTH)}`;
|
return `${prefix}_${hex}${randomBase62(ID_RANDOM_LENGTH)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRetryableFetchError = (error: unknown): boolean => {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
||||||
if (error instanceof TypeError) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureAbsoluteBaseUrl = (candidate: string): string => {
|
const ensureAbsoluteBaseUrl = (candidate: string): string => {
|
||||||
const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api";
|
const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api";
|
||||||
|
|
||||||
@@ -764,8 +756,8 @@ class OpencodeService {
|
|||||||
};
|
};
|
||||||
directory?: string | null;
|
directory?: string | null;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
// Reuse one client-side message ID across retries. The server accepts this
|
// Use the optimistic/client-generated ID as the real user message ID so SSE
|
||||||
// as the real user message ID, making ambiguous network retries idempotent.
|
// can reconcile the echoed server message in-place.
|
||||||
const messageId = params.messageId ?? ascendingId("msg");
|
const messageId = params.messageId ?? ascendingId("msg");
|
||||||
|
|
||||||
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
|
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
|
||||||
@@ -848,74 +840,55 @@ class OpencodeService {
|
|||||||
|
|
||||||
assertProviderCircuitClosed(params.providerID);
|
assertProviderCircuitClosed(params.providerID);
|
||||||
|
|
||||||
let response!: Response;
|
let response: Response;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
try {
|
||||||
try {
|
const result = await this.client.session.promptAsync({
|
||||||
const result = await this.client.session.promptAsync({
|
sessionID: params.id,
|
||||||
sessionID: params.id,
|
...(requestDirectory ? { directory: requestDirectory } : {}),
|
||||||
...(requestDirectory ? { directory: requestDirectory } : {}),
|
model: {
|
||||||
model: {
|
providerID: params.providerID,
|
||||||
providerID: params.providerID,
|
modelID: params.modelID,
|
||||||
modelID: params.modelID,
|
},
|
||||||
},
|
agent: params.agent,
|
||||||
agent: params.agent,
|
variant: params.variant,
|
||||||
variant: params.variant,
|
messageID: messageId,
|
||||||
messageID: messageId,
|
...(params.delivery ? { delivery: params.delivery } : {}),
|
||||||
...(params.delivery ? { delivery: params.delivery } : {}),
|
...(params.format ? { format: params.format } : {}),
|
||||||
...(params.format ? { format: params.format } : {}),
|
parts,
|
||||||
parts,
|
});
|
||||||
});
|
if (result.response instanceof Response) {
|
||||||
if (result.response instanceof Response) {
|
response = result.response;
|
||||||
response = result.response;
|
} else if (result.error) {
|
||||||
} else if (result.error) {
|
const status = (result as SdkResult<unknown>).response?.status || 500;
|
||||||
const status = (result as SdkResult<unknown>).response?.status || 500;
|
response = new Response(JSON.stringify(result.error), { status });
|
||||||
response = new Response(JSON.stringify(result.error), { status });
|
} else {
|
||||||
} else {
|
response = new Response(JSON.stringify(result.data ?? true), { status: 200 });
|
||||||
response = new Response(JSON.stringify(result.data ?? true), { status: 200 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt < 2 && isRetryableFetchError(error)) {
|
|
||||||
const delay = getRetryDelayMs(attempt);
|
|
||||||
console.warn(
|
|
||||||
`[prompt] fetch failed for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms`,
|
|
||||||
(error as Error)?.message
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
recordProviderError(params.providerID);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
if (response.ok) {
|
// Do not retry prompt_async after a transport failure: through a remote
|
||||||
recordProviderSuccess(params.providerID);
|
// tunnel the POST may already be running server-side even though the
|
||||||
return messageId;
|
// client lost the response.
|
||||||
}
|
recordProviderError(params.providerID);
|
||||||
|
|
||||||
if (shouldRetry(params.providerID, response.status, attempt)) {
|
|
||||||
const delay = getRetryDelayMs(attempt);
|
|
||||||
console.warn(
|
|
||||||
`[prompt] ${response.status} for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms`
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = '';
|
|
||||||
try {
|
|
||||||
detail = await response.text();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
|
|
||||||
const error = new Error(`Failed to send message (${response.status})${suffix}`);
|
|
||||||
recordProviderError(params.providerID, response.status);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Defensive fallback — all loop paths return/throw, but TypeScript
|
|
||||||
// control flow analysis cannot prove exhaustiveness without this.
|
if (response.ok) {
|
||||||
throw new Error('Failed to send message after retries');
|
recordProviderSuccess(params.providerID);
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
detail = await response.text();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
|
||||||
|
const error = new Error(`Failed to send message (${response.status})${suffix}`) as Error & { status?: number };
|
||||||
|
error.status = response.status;
|
||||||
|
recordProviderError(params.providerID, response.status);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendCommand(params: {
|
async sendCommand(params: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?
|
|||||||
let questionReplyError: unknown | null = null
|
let questionReplyError: unknown | null = null
|
||||||
let questionRejectError: unknown | null = null
|
let questionRejectError: unknown | null = null
|
||||||
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||||
|
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
||||||
const globalUpsertedSessions: unknown[] = []
|
const globalUpsertedSessions: unknown[] = []
|
||||||
|
|
||||||
const mockScopedClient = {
|
const mockScopedClient = {
|
||||||
@@ -41,7 +42,7 @@ const mockSdk = {
|
|||||||
session: {
|
session: {
|
||||||
messages: mock((params: Record<string, unknown>) => {
|
messages: mock((params: Record<string, unknown>) => {
|
||||||
replyCalls.push({ method: "session.messages", params })
|
replyCalls.push({ method: "session.messages", params })
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve(sessionMessagesResult)
|
||||||
}),
|
}),
|
||||||
revert: mock((params: Record<string, unknown>) => {
|
revert: mock((params: Record<string, unknown>) => {
|
||||||
replyCalls.push({ method: "session.revert", params })
|
replyCalls.push({ method: "session.revert", params })
|
||||||
@@ -343,6 +344,7 @@ describe("optimisticSend target directory", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
replyCalls.length = 0
|
replyCalls.length = 0
|
||||||
scopedClientDirectories.length = 0
|
scopedClientDirectories.length = 0
|
||||||
|
sessionMessagesResult = { data: [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes the prompt directory to optimistic state during session switch races", async () => {
|
test("passes the prompt directory to optimistic state during session switch races", async () => {
|
||||||
@@ -438,6 +440,95 @@ describe("optimisticSend target directory", () => {
|
|||||||
expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race")
|
expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race")
|
||||||
expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle")
|
expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("confirms an ambiguous send failure with a recent message refetch", async () => {
|
||||||
|
const targetStore = createStore({})
|
||||||
|
const childStores = createChildStores([["/target/project", targetStore]])
|
||||||
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
||||||
|
let optimisticConfirm: OptimisticRemoveCall | null = null
|
||||||
|
let sentMessageID = ""
|
||||||
|
|
||||||
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
||||||
|
setOptimisticRefs(
|
||||||
|
() => {},
|
||||||
|
(input) => {
|
||||||
|
optimisticRemove = input
|
||||||
|
},
|
||||||
|
(input) => {
|
||||||
|
optimisticConfirm = input
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await optimisticSend({
|
||||||
|
sessionId: "session-confirmed",
|
||||||
|
directory: "/target/project",
|
||||||
|
content: "hello",
|
||||||
|
providerID: "provider",
|
||||||
|
modelID: "model",
|
||||||
|
send: async (messageID) => {
|
||||||
|
sentMessageID = messageID
|
||||||
|
sessionMessagesResult = {
|
||||||
|
data: [{
|
||||||
|
info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message,
|
||||||
|
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
||||||
|
error.status = 504
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(optimisticRemove).toBe(null)
|
||||||
|
expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID)
|
||||||
|
expect(replyCalls.find((call) => call.method === "session.messages")?.params.limit).toBe(30)
|
||||||
|
expect(targetStore.getState().message["session-confirmed"]?.[0]?.id).toBe(sentMessageID)
|
||||||
|
expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => {
|
||||||
|
const targetStore = createStore({})
|
||||||
|
const childStores = createChildStores([["/target/project", targetStore]])
|
||||||
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
||||||
|
let optimisticConfirm: OptimisticRemoveCall | null = null
|
||||||
|
|
||||||
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
||||||
|
setOptimisticRefs(
|
||||||
|
() => {},
|
||||||
|
(input) => {
|
||||||
|
optimisticRemove = input
|
||||||
|
},
|
||||||
|
(input) => {
|
||||||
|
optimisticConfirm = input
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
let caught: unknown = null
|
||||||
|
try {
|
||||||
|
await optimisticSend({
|
||||||
|
sessionId: "session-missing",
|
||||||
|
directory: "/target/project",
|
||||||
|
content: "hello",
|
||||||
|
providerID: "provider",
|
||||||
|
modelID: "model",
|
||||||
|
send: async () => {
|
||||||
|
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
||||||
|
error.status = 504
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
caught = error
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(Error)
|
||||||
|
expect((optimisticRemove as OptimisticRemoveCall | null)?.sessionID).toBe("session-missing")
|
||||||
|
expect(optimisticConfirm).toBe(null)
|
||||||
|
expect(replyCalls.filter((call) => call.method === "session.messages").every((call) => call.params.limit === 30)).toBe(true)
|
||||||
|
expect(targetStore.getState().session_status["session-missing"]?.type).toBe("idle")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("respondToPermission passes directory", () => {
|
describe("respondToPermission passes directory", () => {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
} from "@/lib/sessionReviewMetadata"
|
} from "@/lib/sessionReviewMetadata"
|
||||||
|
|
||||||
const MESSAGE_REFETCH_LIMIT = 100
|
const MESSAGE_REFETCH_LIMIT = 100
|
||||||
|
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||||
|
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2
|
||||||
|
const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150
|
||||||
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const UNREVERT_REFETCH_ATTEMPTS = 3
|
const UNREVERT_REFETCH_ATTEMPTS = 3
|
||||||
const UNREVERT_REFETCH_RETRY_MS = 150
|
const UNREVERT_REFETCH_RETRY_MS = 150
|
||||||
@@ -39,9 +42,11 @@ let _childStores: ChildStoreManager | null = null
|
|||||||
let _getDirectory: () => string = () => ""
|
let _getDirectory: () => string = () => ""
|
||||||
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
||||||
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
||||||
|
type OptimisticConfirmInput = OptimisticRemoveInput
|
||||||
|
|
||||||
let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
|
let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
|
||||||
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
|
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
|
||||||
|
let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null
|
||||||
|
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
@@ -100,9 +105,11 @@ export function setActionRefs(
|
|||||||
export function setOptimisticRefs(
|
export function setOptimisticRefs(
|
||||||
add: (input: OptimisticAddInput) => void,
|
add: (input: OptimisticAddInput) => void,
|
||||||
remove: (input: OptimisticRemoveInput) => void,
|
remove: (input: OptimisticRemoveInput) => void,
|
||||||
|
confirm?: (input: OptimisticConfirmInput) => void,
|
||||||
) {
|
) {
|
||||||
_optimisticAdd = add
|
_optimisticAdd = add
|
||||||
_optimisticRemove = remove
|
_optimisticRemove = remove
|
||||||
|
_optimisticConfirm = confirm ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdk() {
|
function sdk() {
|
||||||
@@ -189,6 +196,36 @@ function connectionLostError(): Error {
|
|||||||
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
|
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getErrorStatus(error: unknown): number | null {
|
||||||
|
if (!error || typeof error !== "object") return null
|
||||||
|
const direct = (error as { status?: unknown }).status
|
||||||
|
if (typeof direct === "number") return direct
|
||||||
|
const response = (error as { response?: { status?: unknown } }).response
|
||||||
|
return typeof response?.status === "number" ? response.status : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbiguousSendFailure(error: unknown): boolean {
|
||||||
|
const status = getErrorStatus(error)
|
||||||
|
if (status === 503 || status === 504 || status === 408) return true
|
||||||
|
if (error instanceof TypeError) return true
|
||||||
|
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||||
|
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message.toLowerCase()
|
||||||
|
: typeof error === "string"
|
||||||
|
? error.toLowerCase()
|
||||||
|
: ""
|
||||||
|
|
||||||
|
return message.includes("timeout")
|
||||||
|
|| message.includes("timed out")
|
||||||
|
|| message.includes("failed to fetch")
|
||||||
|
|| message.includes("networkerror")
|
||||||
|
|| message.includes("network error")
|
||||||
|
|| message.includes("gateway timeout")
|
||||||
|
|| message.includes("econnreset")
|
||||||
|
|| message.includes("socket hang up")
|
||||||
|
}
|
||||||
|
|
||||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
||||||
@@ -722,6 +759,20 @@ export async function optimisticSend(input: {
|
|||||||
try {
|
try {
|
||||||
await input.send(messageID)
|
await input.send(messageID)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const acceptedRecords = isAmbiguousSendFailure(error)
|
||||||
|
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (acceptedRecords) {
|
||||||
|
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
|
||||||
|
_optimisticConfirm?.({
|
||||||
|
sessionID: input.sessionId,
|
||||||
|
directory: targetDirectory,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Rollback via optimistic infrastructure
|
// Rollback via optimistic infrastructure
|
||||||
_optimisticRemove({
|
_optimisticRemove({
|
||||||
sessionID: input.sessionId,
|
sessionID: input.sessionId,
|
||||||
@@ -739,6 +790,60 @@ export async function optimisticSend(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRecentSendConfirmationRecords(
|
||||||
|
sessionId: string,
|
||||||
|
messageID: string,
|
||||||
|
directory?: string | null,
|
||||||
|
): Promise<Array<{ info: Message; parts?: Part[] }> | null> {
|
||||||
|
for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) {
|
||||||
|
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS)
|
||||||
|
try {
|
||||||
|
const result = await sdk().session.messages({
|
||||||
|
sessionID: sessionId,
|
||||||
|
directory: directory ?? undefined,
|
||||||
|
limit: SEND_CONFIRMATION_REFETCH_LIMIT,
|
||||||
|
})
|
||||||
|
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||||
|
.filter((record: { info?: { id?: string } }) => !!record?.info?.id) as Array<{ info: Message; parts?: Part[] }>
|
||||||
|
if (records.some((record) => record.info.id === messageID)) {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Confirmation is best-effort; if it fails, keep the original send error path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeConfirmedSendRecords(
|
||||||
|
store: DirectoryStoreApi,
|
||||||
|
sessionId: string,
|
||||||
|
messageID: string,
|
||||||
|
records: Array<{ info: Message; parts?: Part[] }>,
|
||||||
|
): void {
|
||||||
|
store.setState((state) => {
|
||||||
|
const currentMessages = state.message[sessionId]
|
||||||
|
const message = { ...state.message }
|
||||||
|
const part = { ...state.part }
|
||||||
|
if (currentMessages) {
|
||||||
|
const nextMessages = currentMessages.filter((message) => message.id !== messageID)
|
||||||
|
message[sessionId] = nextMessages
|
||||||
|
}
|
||||||
|
delete part[messageID]
|
||||||
|
|
||||||
|
const materialized = materializeSessionSnapshots(
|
||||||
|
{ ...state, message, part },
|
||||||
|
sessionId,
|
||||||
|
records.map((record) => ({
|
||||||
|
info: stripMessageDiffSnapshots(record.info),
|
||||||
|
parts: record.parts ?? [],
|
||||||
|
})),
|
||||||
|
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
|
||||||
|
)
|
||||||
|
return { message: materialized.message, part: materialized.part }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Abort
|
// Abort
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -611,6 +611,13 @@ export function useSync() {
|
|||||||
[clearOptimistic, getOptimisticStore],
|
[clearOptimistic, getOptimisticStore],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const optimisticConfirm = useCallback(
|
||||||
|
(input: { sessionID: string; directory?: string | null; messageID: string }) => {
|
||||||
|
clearOptimistic(input.sessionID, input.messageID, input.directory)
|
||||||
|
},
|
||||||
|
[clearOptimistic],
|
||||||
|
)
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
ensureSessionRenderable: syncSession,
|
ensureSessionRenderable: syncSession,
|
||||||
@@ -622,8 +629,9 @@ export function useSync() {
|
|||||||
optimistic: {
|
optimistic: {
|
||||||
add: optimisticAdd,
|
add: optimisticAdd,
|
||||||
remove: optimisticRemove,
|
remove: optimisticRemove,
|
||||||
|
confirm: optimisticConfirm,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove],
|
[syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -602,6 +602,7 @@ Object.defineProperties(openCodeNetworkState, {
|
|||||||
const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({
|
const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({
|
||||||
state: openCodeNetworkState,
|
state: openCodeNetworkState,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args);
|
const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args);
|
||||||
|
|||||||
@@ -2,8 +2,21 @@ export const createOpenCodeNetworkRuntime = (deps) => {
|
|||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
configuredOpenCodeHostname = '127.0.0.1',
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
const resolveConnectHostname = () => {
|
||||||
|
const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : '';
|
||||||
|
const hostname = raw || '127.0.0.1';
|
||||||
|
if (hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]') {
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
return hostname.includes(':') ? `[${hostname}]` : hostname;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeApiPrefix = (prefix) => {
|
const normalizeApiPrefix = (prefix) => {
|
||||||
if (!prefix) {
|
if (!prefix) {
|
||||||
return '';
|
return '';
|
||||||
@@ -77,7 +90,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
|
|||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
|
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
|
||||||
const fullPath = `${prefix}${normalizedPath}`;
|
const fullPath = `${prefix}${normalizedPath}`;
|
||||||
const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`;
|
const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`;
|
||||||
return `${base}${fullPath}`;
|
return `${base}${fullPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,36 +2,56 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { createOpenCodeNetworkRuntime } from './network-runtime.js';
|
import { createOpenCodeNetworkRuntime } from './network-runtime.js';
|
||||||
|
|
||||||
const createRuntime = () => createOpenCodeNetworkRuntime({
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({
|
||||||
state: {
|
state: {
|
||||||
openCodePort: 4096,
|
openCodePort: 4096,
|
||||||
openCodeBaseUrl: null,
|
openCodeBaseUrl: null,
|
||||||
openCodeApiPrefix: '',
|
openCodeApiPrefix: '',
|
||||||
openCodeApiPrefixDetected: false,
|
openCodeApiPrefixDetected: false,
|
||||||
openCodeApiDetectionTimer: null,
|
openCodeApiDetectionTimer: null,
|
||||||
|
...overrides.state,
|
||||||
},
|
},
|
||||||
getOpenCodeAuthHeaders: () => ({}),
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
configuredOpenCodeHostname: overrides.configuredOpenCodeHostname,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OpenCode network runtime', () => {
|
describe('OpenCode network runtime', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the probe abort timer when readiness fetch rejects', async () => {
|
it('returns false when readiness fetch rejects', async () => {
|
||||||
vi.useFakeTimers();
|
globalThis.fetch = vi.fn(async () => {
|
||||||
vi.setSystemTime(0);
|
|
||||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
||||||
throw new Error('offline');
|
throw new Error('offline');
|
||||||
}));
|
});
|
||||||
|
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
const readyPromise = runtime.waitForReady('http://127.0.0.1:4096', 1);
|
const readyPromise = runtime.waitForReady('http://127.0.0.1:4096', 1);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
await expect(readyPromise).resolves.toBe(false);
|
await expect(readyPromise).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
it('builds managed OpenCode URLs against IPv4 loopback by default', () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
|
||||||
|
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps external OpenCode base URLs authoritative', () => {
|
||||||
|
const runtime = createRuntime({
|
||||||
|
state: { openCodeBaseUrl: 'http://remote.example:4096' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
|
||||||
|
expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
|
||||||
|
.toBe('http://127.0.0.1:4096/provider');
|
||||||
|
expect(createRuntime({ configuredOpenCodeHostname: '::1' }).buildOpenCodeUrl('/provider'))
|
||||||
|
.toBe('http://[::1]:4096/provider');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
os,
|
os,
|
||||||
path,
|
path,
|
||||||
OPEN_CODE_READY_GRACE_MS,
|
OPEN_CODE_READY_GRACE_MS,
|
||||||
|
LONG_REQUEST_TIMEOUT_MS,
|
||||||
getRuntime,
|
getRuntime,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
@@ -291,13 +292,48 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
return externalBase;
|
return externalBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeState.openCodePort) {
|
|
||||||
return `http://localhost:${runtimeState.openCodePort}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FALLBACK_PROXY_TARGET;
|
return FALLBACK_PROXY_TARGET;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeProxyTimeout = (value) => {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
|
||||||
|
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
|
||||||
|
|
||||||
|
const isProxyTimeoutError = (error) => {
|
||||||
|
const code = typeof error?.code === 'string' ? error.code : '';
|
||||||
|
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
|
||||||
|
return code === 'ETIMEDOUT'
|
||||||
|
|| code === 'ESOCKETTIMEDOUT'
|
||||||
|
|| message.includes('timeout')
|
||||||
|
|| message.includes('timed out');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendProxyErrorResponse = (res, statusCode) => {
|
||||||
|
if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyProxyResponseDeadline = (req, res, next) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
req[PROXY_TIMEOUT_MARKER] = true;
|
||||||
|
if (sendProxyErrorResponse(res, 504)) {
|
||||||
|
res.once('finish', () => req.destroy?.());
|
||||||
|
}
|
||||||
|
}, PROXY_REQUEST_TIMEOUT_MS);
|
||||||
|
timeout.unref?.();
|
||||||
|
|
||||||
|
const clear = () => clearTimeout(timeout);
|
||||||
|
res.once('finish', clear);
|
||||||
|
res.once('close', clear);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
const forwardSseRequest = async (req, res) => {
|
const forwardSseRequest = async (req, res) => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const closeUpstream = () => abortController.abort();
|
const closeUpstream = () => abortController.abort();
|
||||||
@@ -665,6 +701,8 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
target: resolveProxyTarget(),
|
target: resolveProxyTarget(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { '^/api': '' },
|
pathRewrite: { '^/api': '' },
|
||||||
|
timeout: PROXY_REQUEST_TIMEOUT_MS,
|
||||||
|
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
|
||||||
// Dynamic target — port can change after restart
|
// Dynamic target — port can change after restart
|
||||||
router: () => resolveProxyTarget(),
|
router: () => resolveProxyTarget(),
|
||||||
on: {
|
on: {
|
||||||
@@ -700,11 +738,13 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err, _req, res) => {
|
error: (err, req, res) => {
|
||||||
console.error('[proxy] OpenCode proxy error:', err.message);
|
console.error('[proxy] OpenCode proxy error:', err.message);
|
||||||
if (res && !res.headersSent && typeof res.status === 'function') {
|
if (req?.[PROXY_TIMEOUT_MARKER]) {
|
||||||
res.status(503).json({ error: 'OpenCode service unavailable' });
|
return;
|
||||||
}
|
}
|
||||||
|
const statusCode = isProxyTimeoutError(err) ? 504 : 503;
|
||||||
|
sendProxyErrorResponse(res, statusCode);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -724,5 +764,6 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use('/api', applyProxyResponseDeadline);
|
||||||
app.use('/api', apiProxy);
|
app.use('/api', apiProxy);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -536,4 +536,42 @@ describe('OpenCode proxy SSE forwarding', () => {
|
|||||||
expect(data.body).toEqual(payload);
|
expect(data.body).toEqual(payload);
|
||||||
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the long proxy timeout budget for slow upstream responses', async () => {
|
||||||
|
const upstream = express();
|
||||||
|
upstream.get('/slow', (_req, _res) => {
|
||||||
|
// Leave the response open so the proxy timeout path is exercised.
|
||||||
|
});
|
||||||
|
upstreamServer = await listen(upstream);
|
||||||
|
const upstreamPort = upstreamServer.address().port;
|
||||||
|
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
registerOpenCodeProxy(app, {
|
||||||
|
fs: {},
|
||||||
|
os: {},
|
||||||
|
path,
|
||||||
|
OPEN_CODE_READY_GRACE_MS: 0,
|
||||||
|
LONG_REQUEST_TIMEOUT_MS: 50,
|
||||||
|
getRuntime: () => ({
|
||||||
|
openCodePort: upstreamPort,
|
||||||
|
openCodeBaseUrl: externalBaseUrl,
|
||||||
|
isOpenCodeReady: true,
|
||||||
|
openCodeNotReadySince: 0,
|
||||||
|
isRestartingOpenCode: false,
|
||||||
|
}),
|
||||||
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||||
|
ensureOpenCodeApiPrefix: () => {},
|
||||||
|
});
|
||||||
|
proxyServer = await listen(app);
|
||||||
|
const proxyPort = proxyServer.address().port;
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/slow`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(504);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user