Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13681,6 +13681,125 @@ describe("createAgentChatService", () => {
}));
});

it("emits one Codex failure when error precedes turn/completed with the same payload", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.4",
});

const turn = service.runSessionTurn({
sessionId: session.id,
text: "Continue shipping the fix.",
});
await vi.waitFor(() => {
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
});
mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "turn/started",
params: { turn: { id: "turn-capacity", status: "inProgress" } },
});
const error = {
message: "Selected model is at capacity. Please try a different model.",
codexErrorInfo: "serverOverloaded",
};
mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "error",
params: { turnId: "turn-capacity", error, willRetry: false },
});
mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "turn/completed",
params: {
turn: {
id: "turn-capacity",
status: "failed",
error,
},
},
});

await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-capacity" }));
expect(events.filter((event) => event.event.type === "error")).toHaveLength(1);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
event: expect.objectContaining({
type: "status",
turnStatus: "failed",
turnId: "turn-capacity",
}),
}),
expect.objectContaining({
event: expect.objectContaining({
type: "done",
status: "failed",
turnId: "turn-capacity",
}),
}),
]));
await expect(service.getSessionSummary(session.id)).resolves.toEqual(
expect.objectContaining({ status: "idle" }),
);
});

it("keeps Codex willRetry errors non-terminal until turn/completed", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.4",
});

const turn = service.runSessionTurn({ sessionId: session.id, text: "Retry transiently." });
await vi.waitFor(() => {
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
});
mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "turn/started",
params: { turn: { id: "turn-retry", status: "inProgress" } },
});
mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "error",
params: {
turnId: "turn-retry",
willRetry: true,
error: { message: "Temporary upstream failure.", codexErrorInfo: "serverOverloaded" },
},
});

expect(events.filter((event) => event.event.type === "error")).toHaveLength(0);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
event: expect.objectContaining({
type: "system_notice",
noticeKind: "provider_health",
turnId: "turn-retry",
}),
}),
]));
await expect(service.getSessionSummary(session.id)).resolves.toEqual(
expect.objectContaining({ status: "active" }),
);

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "turn/completed",
params: { turn: { id: "turn-retry", status: "completed" } },
});
await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-retry" }));
});

it("ignores stale Codex lifecycle notifications from a foreign turn", async () => {
const events: Array<{ type: string; turnId?: string; text?: string }> = [];
const { service } = createService({
Expand Down
78 changes: 64 additions & 14 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ type CodexRuntime = {
agentMessageScopeByTurn: Map<string, "item" | "turn">;
agentMessageTextByTurn: Map<string, string>;
recentNotificationKeys: Set<string>;
emittedErrorKeys: Set<string>;
reconciledItemSignaturesByTurn: Map<string, Set<string>>;
noFirstEventWatchdog: {
turnId: string;
Expand Down Expand Up @@ -20105,6 +20106,38 @@ export function createAgentChatService(args: {
persistChatState(managed);
}

function emitCodexErrorOnce(
managed: ManagedChatSession,
runtime: CodexRuntime,
input: {
turnId?: string | null;
message: unknown;
errorInfo?: unknown;
detail?: unknown;
},
): void {
const turnId = typeof input.turnId === "string" && input.turnId.trim().length
? input.turnId.trim()
: null;
const message = String(input.message ?? "Codex app-server error.");
const errorInfo = formatCodexErrorInfo(input.errorInfo);
const detail = typeof input.detail === "string" && input.detail.trim().length
? input.detail.trim()
: undefined;
if (turnId) {
const semanticKey = JSON.stringify([turnId, message.trim(), errorInfo ?? null, detail ?? null]);
if (runtime.emittedErrorKeys.has(semanticKey)) return;
rememberBoundedId(runtime.emittedErrorKeys, semanticKey, 512);
}
emitChatEvent(managed, {
type: "error",
message,
...(turnId ? { turnId } : {}),
...(errorInfo ? { errorInfo } : {}),
...(detail ? { detail } : {}),
});
}

async function finishCodexTurnFromReconciledState(
managed: ManagedChatSession,
runtime: CodexRuntime,
Expand Down Expand Up @@ -20148,11 +20181,11 @@ export function createAgentChatService(args: {
const error = asRecord(turn.error);
const errorMessage = stringOrNull(error?.message);
if (status === "failed" && errorMessage) {
emitChatEvent(managed, {
type: "error",
message: errorMessage,
emitCodexErrorOnce(managed, runtime, {
turnId,
errorInfo: formatCodexErrorInfo(error?.codexErrorInfo),
message: errorMessage,
errorInfo: error?.codexErrorInfo,
detail: error?.additionalDetails,
});
}

Expand Down Expand Up @@ -20599,11 +20632,11 @@ export function createAgentChatService(args: {
}

if (status === "failed" && turn?.error?.message) {
emitChatEvent(managed, {
type: "error",
message: String(turn.error.message),
emitCodexErrorOnce(managed, runtime, {
turnId,
errorInfo: formatCodexErrorInfo(turn.error.codexErrorInfo)
message: turn.error.message,
errorInfo: turn.error.codexErrorInfo,
detail: (turn.error as { additionalDetails?: unknown }).additionalDetails,
});
}

Expand Down Expand Up @@ -20997,12 +21030,28 @@ export function createAgentChatService(args: {
}

if (method === "error") {
const error = (params.error as { message?: unknown; codexErrorInfo?: unknown } | null) ?? null;
emitChatEvent(managed, {
type: "error",
message: String(error?.message ?? "Codex app-server error."),
turnId: typeof params.turnId === "string" ? params.turnId : undefined,
errorInfo: formatCodexErrorInfo(error?.codexErrorInfo)
const error = (params.error as {
message?: unknown;
codexErrorInfo?: unknown;
additionalDetails?: unknown;
} | null) ?? null;
const turnId = typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined;
if (params.willRetry === true) {
emitChatEvent(managed, {
type: "system_notice",
noticeKind: "provider_health",
severity: "warning",
message: "Codex hit a provider error and is retrying automatically.",
detail: String(error?.message ?? "The provider request failed."),
...(turnId ? { turnId } : {}),
});
return;
}
emitCodexErrorOnce(managed, runtime, {
turnId,
message: error?.message ?? "Codex app-server error.",
errorInfo: error?.codexErrorInfo,
detail: error?.additionalDetails,
});
return;
}
Expand Down Expand Up @@ -21224,6 +21273,7 @@ export function createAgentChatService(args: {
agentMessageScopeByTurn: new Map<string, "item" | "turn">(),
agentMessageTextByTurn: new Map<string, string>(),
recentNotificationKeys: new Set<string>(),
emittedErrorKeys: new Set<string>(),
reconciledItemSignaturesByTurn: new Map<string, Set<string>>(),
noFirstEventWatchdog: null,
stalledTurnIds: new Set<string>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,8 @@ export function AgentChatComposer({
appControlContextItems = [],
builtInBrowserContextItems = [],
modelSelectionLocked = false,
modelPickerOpenRequestKey,
onModelPickerOpenRequestHandled,
permissionModeLocked = false,
hideNativeControls = false,
orchestrationRole = null,
Expand Down Expand Up @@ -1183,6 +1185,8 @@ export function AgentChatComposer({
builtInBrowserContextItems?: BuiltInBrowserContextItem[];
executionModeOptions?: ExecutionModeOption[];
modelSelectionLocked?: boolean;
modelPickerOpenRequestKey?: number;
onModelPickerOpenRequestHandled?: () => void;
permissionModeLocked?: boolean;
hideNativeControls?: boolean;
/**
Expand Down Expand Up @@ -3849,6 +3853,8 @@ export function AgentChatComposer({
value={modelId}
onChange={onModelChange}
surfaceKey="chat-composer"
openRequestKey={modelPickerOpenRequestKey}
onOpenRequestHandled={onModelPickerOpenRequestHandled}
{...(availableModelIds ? { availableModelIds } : {})}
constrainToAvailableModelIds={constrainModelSelection}
{...(providerAuthStatus ? { providerAuthStatus } : {})}
Expand Down
Loading
Loading