diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 9df7377a3..0febb5810 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -389,6 +389,7 @@ const SILENCED_EVENT_TYPES = new Set([ "error", "tokens", "codex_token_usage", + "codex_turn_stalled", "codex_goal_updated", "codex_goal_cleared", "pending_input_resolved", diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 151a85bab..78999e953 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -295,6 +295,8 @@ describe("buildCodingAgentSystemPrompt", () => { it("always includes operating loop, editing rules, and verification rules", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); expect(result).toContain("## Operating Loop"); + expect(result).toContain("status checks, interruptions, and tool/subagent timeouts as checkpoints"); + expect(result).toContain("unless the user explicitly says stop, pause, or only report status"); expect(result).toContain("## ADE"); expect(result).toContain("read the matching `ade-*` skill"); expect(result).toContain("Your ADE capabilities ship as Agent Skills"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 0ee77d58e..a786113ae 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -237,6 +237,7 @@ export function buildCodingAgentSystemPrompt(args: { "3. When you mutate code, keep edits narrow, preserve surrounding conventions, and avoid speculative rewrites.", "4. Verify every meaningful change with diffs, tests, type checks, or targeted inspection.", "5. Only finish once the task is complete or you are truly blocked.", + "6. Treat status checks, interruptions, and tool/subagent timeouts as checkpoints. Give the requested status, then continue the active directive unless the user explicitly says stop, pause, or only report status.", "", "## User-Facing Progress", "Before the first meaningful tool burst, send one short preamble sentence describing what you are about to do.", diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d16d07bca..0ce548b80 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -149,6 +149,16 @@ vi.mock("node:child_process", () => ({ } else if (payload.method === "turn/start" || payload.method === "review/start") { mockState.codexTurnCounter += 1; result = { turn: { id: `turn-${mockState.codexTurnCounter}` } }; + } else if (payload.method === "thread/read") { + const params = payload.params as { threadId?: unknown } | undefined; + result = { + thread: { + id: typeof params?.threadId === "string" ? params.threadId : "thread-1", + status: { type: "active", activeFlags: [] }, + }, + }; + } else if (payload.method === "thread/turns/list") { + result = { data: [], nextCursor: null }; } else if (payload.method === "collaborationMode/list") { result = { collaborationModes: mockState.codexCollaborationModes, @@ -1159,7 +1169,7 @@ function createMockSessionService() { resumeCommand: args.resumeCommand ?? null, lastOutputPreview: null, summary: null, - goal: null, + goal: args.goal ?? null, manuallyNamed: false, headShaStart: null, headShaEnd: null, @@ -1727,6 +1737,27 @@ describe("createAgentChatService", () => { expect(sessionService.create).toHaveBeenCalledTimes(1); }); + it("persists create-time goals into the backing session row", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + goal: "Run quality, tests, ship, merge, and release.", + }); + + expect(session.goal).toBe("Run quality, tests, ship, merge, and release."); + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: session.id, + goal: "Run quality, tests, ship, merge, and release.", + }), + ); + await expect(service.getSessionSummary(session.id)).resolves.toMatchObject({ + goal: "Run quality, tests, ship, merge, and release.", + }); + }); + it("creates a claude session with default model", async () => { const { service } = createService(); const session = await service.createSession({ @@ -13729,6 +13760,563 @@ describe("createAgentChatService", () => { )).toBe(false); }); + it("seeds create-time ADE goals into the Codex app-server goal before the first turn", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective, + status: params.status ?? "active", + tokenBudget: params.tokenBudget, + }, + }; + }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + goal: "Run quality, tests, ship, merge, and release.", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Continue the work.", + }, { awaitDispatch: true }); + + const goalRequestIndex = mockState.codexRequestPayloads.findIndex((payload) => payload.method === "thread/goal/set"); + const turnRequestIndex = mockState.codexRequestPayloads.findIndex((payload) => payload.method === "turn/start"); + expect(goalRequestIndex).toBeGreaterThan(-1); + expect(turnRequestIndex).toBeGreaterThan(-1); + expect(goalRequestIndex).toBeLessThan(turnRequestIndex); + expect(mockState.codexRequestPayloads[goalRequestIndex]?.params).toMatchObject({ + threadId: "thread-1", + objective: "Run quality, tests, ship, merge, and release.", + status: "active", + tokenBudget: null, + }); + expect((await service.getSessionSummary(session.id))?.codexGoal).toMatchObject({ + objective: "Run quality, tests, ship, merge, and release.", + status: "active", + tokenBudget: null, + }); + }); + + it("surfaces Codex MCP startup failures without treating them as turn progress", async () => { + vi.useFakeTimers(); + try { + 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.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + method: "mcpServer/startupStatus/updated", + params: { + serverName: "local-tools", + status: "failed", + message: "http/request failed: error sending request", + }, + }); + mockState.emitCodexPayload({ + method: "mcpServer/startupStatus/updated", + params: { + serverName: "local-tools", + status: "failed", + message: "http/request failed: error sending request", + }, + }); + + await Promise.resolve(); + const mcpNotices = events.filter((event) => + event.event.type === "system_notice" + && event.event.message.includes("Codex MCP server 'local-tools' is unavailable") + ); + expect(mcpNotices).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "codex_turn_stalled" + && event.event.reason === "no_output" + )).toBe(true); + }); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message.includes("has not streamed model or tool output yet") + )).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the Codex no-output watchdog when an approval request is surfaced", async () => { + vi.useFakeTimers(); + try { + 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.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + id: "approval-1", + method: "item/commandExecution/requestApproval", + params: { + itemId: "cmd-1", + turnId: "turn-1", + command: "npm test", + cwd: ".", + reason: "Run tests", + }, + }); + + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "approval_request" + && event.event.itemId === "cmd-1" + )).toBe(true); + }); + + await vi.advanceTimersByTimeAsync(120_000); + + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message.includes("has not streamed model or tool output yet") + )).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("reconciles a completed silent Codex turn from app-server state before reporting a stall", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + mockState.codexResponseOverrides.set("thread/turns/list", () => ({ + data: [ + { + id: "turn-1", + status: "completed", + usage: { inputTokens: 7, outputTokens: 3 }, + items: [ + { + id: "msg-1", + type: "agentMessage", + text: "Recovered assistant output.", + }, + ], + }, + ], + nextCursor: null, + })); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "done" + && event.event.turnId === "turn-1" + && event.event.status === "completed" + )).toBe(true); + }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/read")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/turns/list")).toBe(true); + expect(events.some((event) => + event.event.type === "text" + && event.event.text.includes("Recovered assistant output.") + )).toBe(true); + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("does not complete a reconciled MCP tool call while app-server still reports it running", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + mockState.codexResponseOverrides.set("thread/turns/list", () => ({ + data: [ + { + id: "turn-1", + status: "inProgress", + items: [ + { + id: "mcp-1", + type: "mcpToolCall", + server: "local-tools", + tool: "probe", + status: "running", + arguments: { path: "README.md" }, + }, + ], + }, + ], + nextCursor: null, + })); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "tool_call" + && event.event.itemId === "mcp-1" + )).toBe(true); + }); + + expect(events.some((event) => + event.event.type === "tool_result" + && event.event.itemId === "mcp-1" + )).toBe(false); + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("re-arms the Codex watchdog after partial same-thread recovery", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let turnsListCalls = 0; + mockState.codexResponseOverrides.set("thread/turns/list", () => { + turnsListCalls += 1; + return { + data: [ + { + id: "turn-1", + status: "inProgress", + items: [ + { + id: "reasoning-1", + type: "reasoning", + summary: ["Recovered partial reasoning."], + }, + ], + }, + ], + nextCursor: null, + }; + }); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "reasoning" + && event.event.text.includes("Recovered partial reasoning.") + )).toBe(true); + }); + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "codex_turn_stalled" + && event.event.reason === "no_output" + )).toBe(true); + }); + expect(events.filter((event) => + event.event.type === "reasoning" + && event.event.text.includes("Recovered partial reasoning.") + )).toHaveLength(1); + expect(turnsListCalls).toBeGreaterThanOrEqual(2); + } finally { + vi.useRealTimers(); + } + }); + + it("does not double-finalize when a normal Codex completion wins the silent-turn reconciliation race", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + mockState.delayedCodexMethods.add("thread/turns/list"); + mockState.codexResponseOverrides.set("thread/turns/list", () => ({ + data: [ + { + id: "turn-1", + status: "completed", + usage: { inputTokens: 7, outputTokens: 3 }, + items: [ + { + id: "msg-after-complete", + type: "agentMessage", + text: "Recovered after the normal completion.", + }, + ], + }, + ], + nextCursor: null, + })); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(mockState.pendingCodexResponses).toHaveLength(1); + }); + + mockState.emitCodexPayload({ + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + usage: { inputTokens: 11, outputTokens: 5 }, + }, + }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" + && event.event.turnId === "turn-1" + && event.event.status === "completed", + ); + + mockState.flushCodexResponses(); + await Promise.resolve(); + + const doneEvents = events.filter((event) => + event.event.type === "done" + && event.event.turnId === "turn-1" + && event.event.status === "completed" + ); + expect(doneEvents).toHaveLength(1); + expect(events.some((event) => + event.event.type === "text" + && event.event.text.includes("Recovered after the normal completion.") + )).toBe(false); + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("does not emit a stale stall when a normal Codex completion wins after turns-list fails", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + mockState.delayedCodexMethods.add("thread/turns/list"); + mockState.codexResponseOverrides.set("thread/turns/list", () => ({ + error: { code: -32000, message: "thread state unavailable" }, + })); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(mockState.pendingCodexResponses).toHaveLength(1); + }); + + mockState.emitCodexPayload({ + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + usage: { inputTokens: 11, outputTokens: 5 }, + }, + }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" + && event.event.turnId === "turn-1" + && event.event.status === "completed", + ); + + mockState.flushCodexResponses(); + await Promise.resolve(); + + expect(events.filter((event) => + event.event.type === "done" + && event.event.turnId === "turn-1" + && event.event.status === "completed" + )).toHaveLength(1); + expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false); + expect(events.some((event) => + event.event.type === "system_notice" + && ( + event.event.message.includes("has not streamed model or tool output yet") + || event.event.message.includes("could not confirm its app-server state") + ) + )).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("routes structured Codex stall notices to an orchestration parent without auto-handoff", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const parent = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + orchestrationRole: "lead", + }); + const child = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + orchestrationRole: "worker", + orchestrationParentSessionId: parent.id, + }); + + await service.sendMessage({ + sessionId: child.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "codex_turn_stalled" + && event.event.sourceSessionId === child.id + )).toBe(true); + }); + + expect(events.some((event) => + event.sessionId === child.id + && event.event.type === "codex_turn_stalled" + && event.event.reason === "no_output" + )).toBe(true); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.message.includes("Child Codex session") + )).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/interrupt")).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the Codex no-output watchdog when useful turn events arrive", async () => { + vi.useFakeTimers(); + try { + 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.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep working.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + method: "item/started", + params: { + turnId: "turn-1", + item: { id: "item-1", type: "agentMessage" }, + }, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(120_000); + + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message.includes("has not streamed model or tool output yet") + )).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("exposes typed Codex goal controls with unlimited budgets and persisted summaries", async () => { mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { const params = payload.params as Record; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4085d0a01..eb33ec304 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -613,6 +613,14 @@ type CodexRuntime = { agentMessageScopeByTurn: Map; agentMessageTextByTurn: Map; recentNotificationKeys: Set; + reconciledItemSignaturesByTurn: Map>; + noFirstEventWatchdog: { + turnId: string; + timer: NodeJS.Timeout; + } | null; + stalledTurnIds: Set; + stallReconcileInFlight: Set; + mcpStartupNoticeKeys: Set; /** * Plan-approval follow-ups deferred until the planning turn idles. Calling * sendMessage while a planning turn is still active would race the busy @@ -1897,8 +1905,10 @@ const DEFAULT_RUN_SESSION_TURN_TIMEOUT_MS = 300_000; const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; const CODEX_REQUEST_TIMEOUT_MS = 30_000; const CODEX_INLINE_COMMAND_TIMEOUT_MS = 10_000; +const CODEX_STALL_RECONCILE_TIMEOUT_MS = 10_000; const CODEX_INTERRUPT_REQUEST_TIMEOUT_MS = 2_500; const CODEX_ARCHIVE_REQUEST_TIMEOUT_MS = 3_000; +const CODEX_NO_FIRST_EVENT_WATCHDOG_MS = 120_000; const CODEX_GOAL_OBJECTIVE_MAX_CHARS = 4_000; const CODEX_GOAL_OBJECTIVE_REQUIRED_MESSAGE = "Goal text is required."; const CODEX_GOAL_OBJECTIVE_TOO_LONG_MESSAGE = "Goal is too long. Keep it under 4,000 characters."; @@ -9699,6 +9709,225 @@ export function createAgentChatService(args: { return true; }; + const clearCodexNoFirstEventWatchdog = (runtime: CodexRuntime): void => { + if (!runtime.noFirstEventWatchdog) return; + clearTimeout(runtime.noFirstEventWatchdog.timer); + runtime.noFirstEventWatchdog = null; + }; + + const scheduleCodexNoFirstEventWatchdog = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + turnId: string | null | undefined, + ): void => { + const normalizedTurnId = turnId?.trim() || null; + if (!normalizedTurnId) return; + if (runtime.noFirstEventWatchdog?.turnId === normalizedTurnId) return; + clearCodexNoFirstEventWatchdog(runtime); + const timer = setTimeout(() => { + if ( + managed.deleted + || managed.closed + || managed.runtime !== runtime + || (runtime.activeTurnId ?? runtime.startedTurnId) !== normalizedTurnId + ) { + if (runtime.noFirstEventWatchdog?.turnId === normalizedTurnId) { + runtime.noFirstEventWatchdog = null; + } + return; + } + runtime.noFirstEventWatchdog = null; + void reconcileCodexSilentTurn(managed, runtime, normalizedTurnId).catch((error) => { + logger.warn("agent_chat.codex_stall_reconcile_failed", { + sessionId: managed.session.id, + turnId: normalizedTurnId, + error: error instanceof Error ? error.message : String(error), + }); + emitCodexTurnStalled(managed, runtime, { + turnId: normalizedTurnId, + reason: "app_server_state_unknown", + message: "Codex accepted this turn but ADE could not confirm its app-server state. You can keep waiting, send a status nudge, or interrupt and retry this thread if it stays stalled.", + }); + }); + }, CODEX_NO_FIRST_EVENT_WATCHDOG_MS); + timer.unref?.(); + runtime.noFirstEventWatchdog = { + turnId: normalizedTurnId, + timer, + }; + }; + + const markCodexTurnProgress = ( + runtime: CodexRuntime, + turnId: string | null | undefined, + ): void => { + const normalizedTurnId = turnId?.trim() || runtime.activeTurnId || runtime.startedTurnId || null; + if (!runtime.noFirstEventWatchdog) return; + if (!normalizedTurnId || runtime.noFirstEventWatchdog.turnId === normalizedTurnId) { + clearCodexNoFirstEventWatchdog(runtime); + } + }; + + const isCodexUsefulProgressNotification = ( + method: string, + params: Record, + ): boolean => { + if (method === "item/agentMessage/delta") { + return typeof params.delta === "string" && params.delta.length > 0; + } + if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { + return typeof params.delta === "string" && params.delta.length > 0; + } + if (method === "item/commandExecution/outputDelta" || method === "item/fileChange/outputDelta") { + return typeof params.delta === "string" && params.delta.length > 0; + } + return method === "item/started" + || method === "item/completed" + || method === "codex/event/item_started" + || method === "codex/event/item_completed" + || method === "turn/completed" + || method === "turn/aborted" + || method === "codex/event/turn_aborted" + || method === "turn/plan/updated" + || method === "item/plan/delta" + || method === "codex/event/web_search_begin" + || method === "thread/tokenUsage/updated" + || method === "error"; + }; + + const normalizeCodexMcpStartupStatus = ( + params: Record, + ): { serverName: string; status: string | null; message: string | null; failed: boolean } => { + const serverRecord = asRecord(params.server) + ?? asRecord(params.mcpServer) + ?? asRecord(params.status) + ?? {}; + const errorRecord = asRecord(params.error) ?? asRecord(serverRecord.error); + const serverName = stringOrNull( + params.serverName + ?? params.server_name + ?? params.name + ?? serverRecord.serverName + ?? serverRecord.server_name + ?? serverRecord.name + ?? serverRecord.id, + ) ?? "MCP server"; + const status = stringOrNull( + params.status + ?? params.state + ?? params.phase + ?? serverRecord.status + ?? serverRecord.state + ?? serverRecord.phase, + )?.toLowerCase() ?? null; + const message = stringOrNull( + params.message + ?? params.reason + ?? params.detail + ?? params.error + ?? serverRecord.message + ?? serverRecord.reason + ?? serverRecord.detail + ?? serverRecord.error + ?? errorRecord?.message, + ); + const failed = Boolean(message && /(?:fail|error|closed|refused|timeout|unavailable)/i.test(message)) + || Boolean(status && /(?:fail|error|closed|refused|timeout|unavailable)/i.test(status)); + return { serverName, status, message, failed }; + }; + + const handleCodexMcpStartupStatus = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + params: Record, + ): void => { + const status = normalizeCodexMcpStartupStatus(params); + if (!status.failed) { + logger.debug("agent_chat.codex_mcp_startup_status", { + sessionId: managed.session.id, + serverName: status.serverName, + status: status.status, + }); + return; + } + const key = `${status.serverName}:${status.status ?? ""}:${status.message ?? ""}`; + if (runtime.mcpStartupNoticeKeys.has(key)) return; + runtime.mcpStartupNoticeKeys.add(key); + if (runtime.mcpStartupNoticeKeys.size > 128) { + const [first] = runtime.mcpStartupNoticeKeys; + if (first) runtime.mcpStartupNoticeKeys.delete(first); + } + const suffix = status.message ? `: ${status.message}` : status.status ? ` (${status.status})` : ""; + logger.warn("agent_chat.codex_mcp_startup_failed", { + sessionId: managed.session.id, + serverName: status.serverName, + status: status.status, + message: status.message, + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message: `Codex MCP server '${status.serverName}' is unavailable${suffix}.`, + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + }); + persistChatState(managed); + }; + + const seedCodexThreadGoalFromSessionGoal = async ( + managed: ManagedChatSession, + runtime: CodexRuntime, + ): Promise => { + const threadId = managed.session.threadId?.trim(); + if (!threadId || managed.session.codexGoal?.objective?.trim()) return; + const objective = normalizeCodexGoalObjectiveText(managed.session.goal); + if (!objective) return; + let normalizedObjective: string; + try { + normalizedObjective = validateCodexGoalObjectiveText(objective); + } catch (error) { + logger.warn("agent_chat.codex_goal_seed_invalid", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message: `Codex goal sync skipped: ${error instanceof Error ? error.message : String(error)}`, + }); + return; + } + try { + const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", codexGoalSetParams({ + threadId, + objective: normalizedObjective, + status: "active", + }), { timeoutMs: CODEX_INLINE_COMMAND_TIMEOUT_MS }); + const goal = normalizeCodexGoalPayload(response) + ?? { + objective: normalizedObjective, + status: "active" as const, + tokenBudget: null, + }; + setCodexGoalAndMaybeEmitUpdate(managed, runtime, goal, "set"); + persistChatState(managed); + } catch (error) { + logger.warn("agent_chat.codex_goal_seed_failed", { + sessionId: managed.session.id, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message: `Codex goal sync failed: ${error instanceof Error ? error.message : String(error)}`, + }); + persistChatState(managed); + } + }; + const emitPendingInputRequest = ( managed: ManagedChatSession, request: PendingInputRequest, @@ -10210,6 +10439,7 @@ export function createAgentChatService(args: { && managed.session.status === "active" && !managed.deleted; runtime.suppressExitError = true; + clearCodexNoFirstEventWatchdog(runtime); try { runtime.reader.close(); } catch { /* ignore */ } runtime.killTimer = terminateChildProcessTree( runtime.process, @@ -10575,6 +10805,9 @@ export function createAgentChatService(args: { : undefined); const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); + const rowGoal = typeof row.goal === "string" && row.goal.trim().length + ? row.goal.trim() + : null; const managed: ManagedChatSession = { session: { @@ -10584,6 +10817,7 @@ export function createAgentChatService(args: { model, ...(hydratedModelId ? { modelId: hydratedModelId } : {}), ...(persisted?.sessionProfile ? { sessionProfile: persisted.sessionProfile } : {}), + ...(rowGoal ? { goal: rowGoal } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, fastMode: persisted?.fastMode === true, executionMode: persisted?.executionMode ?? null, @@ -11147,6 +11381,7 @@ export function createAgentChatService(args: { return; } runtime.activeTurnId = reviewTurnId; + scheduleCodexNoFirstEventWatchdog(managed, runtime, reviewTurnId); } return; } @@ -11353,6 +11588,7 @@ export function createAgentChatService(args: { return; } managed.runtime.activeTurnId = turnId; + scheduleCodexNoFirstEventWatchdog(managed, managed.runtime, turnId); if (managed.runtime.startedTurnId !== turnId) { managed.runtime.startedTurnId = turnId; emitChatEvent(managed, { @@ -14631,6 +14867,7 @@ export function createAgentChatService(args: { turnId: requestTurnId, }; runtime.approvals.set(itemId, { requestId: id, kind: "command", request }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "command", description, @@ -14679,6 +14916,7 @@ export function createAgentChatService(args: { turnId: requestTurnId, }; runtime.approvals.set(itemId, { requestId: id, kind: "file_change", request }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "file_change", description, @@ -14768,6 +15006,7 @@ export function createAgentChatService(args: { permissions: params.permissions ?? null, request, }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "tool_call", description, @@ -14848,6 +15087,7 @@ export function createAgentChatService(args: { request, questionResponseKind: "native_request_user_input", }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "tool_call", description: request.description ?? "Codex requested input", @@ -15000,6 +15240,7 @@ export function createAgentChatService(args: { kind: "plan_approval", request, }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "tool_call", description: "Plan ready for approval", @@ -15088,6 +15329,7 @@ export function createAgentChatService(args: { kind: "plan_approval", request, }); + markCodexTurnProgress(runtime, request.turnId); emitPendingInputRequest(managed, request, { kind: "tool_call", description: "Plan ready for approval", @@ -15207,6 +15449,7 @@ export function createAgentChatService(args: { summary: string, ): void => { const interruptedTurnId = turnId?.trim() || runtime.activeTurnId || runtime.startedTurnId || randomUUID(); + clearCodexNoFirstEventWatchdog(runtime); rememberInterruptedCodexTurn(runtime, interruptedTurnId); rememberTerminalCodexTurn(runtime, interruptedTurnId, managed); runtime.awaitingTurnStart = false; @@ -15226,6 +15469,7 @@ export function createAgentChatService(args: { runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); + runtime.reconciledItemSignaturesByTurn.clear(); for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { itemId: followup.itemId, @@ -16408,6 +16652,451 @@ export function createAgentChatService(args: { logger.debug("agent_chat.codex_unhandled_item", { sessionId: managed.session.id, itemType, itemId }); }; + function codexTurnStatusString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; + } + + function isTerminalCodexTurnStatusValue(value: unknown): boolean { + const status = codexTurnStatusString(value); + return status === "completed" || status === "interrupted" || status === "failed"; + } + + function codexTurnItems(turn: Record | null | undefined): Record[] { + const items = Array.isArray(turn?.items) ? turn.items : []; + return items.filter((item): item is Record => + Boolean(item && typeof item === "object" && !Array.isArray(item)) + ); + } + + function isCodexReconciledItemUseful(item: Record): boolean { + const itemType = stringOrNull(item.type); + return itemType === "agentMessage" + || itemType === "reasoning" + || itemType === "plan" + || itemType === "planning" + || itemType === "planningItem" + || itemType === "commandExecution" + || itemType === "fileChange" + || itemType === "toolCall" + || itemType === "dynamicToolCall" + || itemType === "mcpToolCall" + || itemType === "webSearch" + || itemType === "imageGeneration" + || itemType === "imageView" + || itemType === "delegation" + || itemType === "collabAgentToolCall" + || itemType === "collabToolCall"; + } + + function isCodexReconciledItemInProgress(value: unknown): boolean { + const status = String(value ?? "").toLowerCase(); + return status === "inprogress" || status === "in_progress" || status === "running"; + } + + function codexReconciledItemSignature(item: Record, itemId: string): string { + return stableStringify({ + itemId, + type: stringOrNull(item.type), + status: stringOrNull(item.status), + text: stringOrNull(item.text ?? item.content ?? item.message ?? item.markdown ?? item.description ?? item.planText), + summary: Array.isArray(item.summary) ? item.summary : null, + result: item.result ?? null, + error: item.error ?? null, + arguments: item.arguments ?? null, + }); + } + + function hasReconciledItemSignature(runtime: CodexRuntime, turnId: string, signature: string): boolean { + return runtime.reconciledItemSignaturesByTurn.get(turnId)?.has(signature) === true; + } + + function rememberReconciledItemSignature(runtime: CodexRuntime, turnId: string, signature: string): void { + let signatures = runtime.reconciledItemSignaturesByTurn.get(turnId); + if (!signatures) { + signatures = new Set(); + runtime.reconciledItemSignaturesByTurn.set(turnId, signatures); + } + signatures.add(signature); + if (signatures.size > 512) { + signatures.clear(); + signatures.add(signature); + } + if (runtime.reconciledItemSignaturesByTurn.size > 64) { + const [firstTurnId] = runtime.reconciledItemSignaturesByTurn.keys(); + if (firstTurnId) runtime.reconciledItemSignaturesByTurn.delete(firstTurnId); + } + } + + function emitCodexReconciledItem( + managed: ManagedChatSession, + runtime: CodexRuntime, + item: Record, + itemIndex: number, + turnId: string, + ): boolean { + if (!isCodexReconciledItemUseful(item)) return false; + const itemId = stringOrNull(item.id) ?? `reconciled:${turnId}:${itemIndex}`; + const signature = codexReconciledItemSignature(item, itemId); + if (hasReconciledItemSignature(runtime, turnId, signature)) return false; + const itemType = stringOrNull(item.type) ?? ""; + if (itemType === "agentMessage") { + const text = stringOrNull(item.text ?? item.content ?? item.message); + if (!text) return false; + const normalizedText = normalizeCodexAssistantDelta(runtime, { + turnId, + itemId, + delta: text, + }); + if (!normalizedText) return false; + emitChatEvent(managed, { + type: "text", + text: normalizedText, + itemId, + turnId, + }); + rememberReconciledItemSignature(runtime, turnId, signature); + return true; + } + if (itemType === "reasoning") { + const summary = Array.isArray(item.summary) + ? item.summary.filter((entry): entry is string => typeof entry === "string") + : []; + const content = Array.isArray(item.content) + ? item.content.filter((entry): entry is string => typeof entry === "string") + : []; + const text = [...summary, ...content].join("\n").trim(); + if (!text) return false; + emitChatEvent(managed, { + type: "reasoning", + text, + itemId, + turnId, + }); + rememberReconciledItemSignature(runtime, turnId, signature); + return true; + } + if (itemType === "mcpToolCall") { + const tool = stringOrNull(item.tool) ?? "mcp_tool"; + const server = stringOrNull(item.server); + const label = server ? `${server}:${tool}` : tool; + emitChatEvent(managed, { + type: "tool_call", + tool: label, + args: item.arguments ?? null, + itemId, + turnId, + }); + if (!isCodexReconciledItemInProgress(item.status)) { + emitChatEvent(managed, { + type: "tool_result", + tool: label, + result: item.result ?? item.error ?? "", + itemId, + turnId, + status: item.error ? "failed" : "completed", + }); + } + rememberReconciledItemSignature(runtime, turnId, signature); + return true; + } + const eventKind = isCodexReconciledItemInProgress(item.status) + ? "started" + : "completed"; + handleCodexItemEvent(managed, runtime, { ...item, id: itemId }, eventKind, turnId); + rememberReconciledItemSignature(runtime, turnId, signature); + return true; + } + + function activeFlagsFromThreadReadResponse(value: unknown): string[] { + const root = asRecord(value); + const thread = asRecord(root?.thread) ?? root; + const status = asRecord(thread?.status); + const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : []; + return flags.filter((flag): flag is string => typeof flag === "string"); + } + + function emitCodexTurnStalled( + managed: ManagedChatSession, + runtime: CodexRuntime, + args: { + turnId: string; + reason: Extract["reason"]; + message: string; + }, + ): void { + if (runtime.stalledTurnIds.has(args.turnId)) return; + rememberBoundedId(runtime.stalledTurnIds, args.turnId); + const recoveryOptions: Extract["recoveryOptions"] = [ + "wait", + "steer", + "interrupt_retry_same_thread", + "restart_resume_thread", + ]; + logger.warn("agent_chat.codex_turn_stalled", { + sessionId: managed.session.id, + threadId: managed.session.threadId ?? null, + turnId: args.turnId, + reason: args.reason, + timeoutMs: CODEX_NO_FIRST_EVENT_WATCHDOG_MS, + }); + emitChatEvent(managed, { + type: "codex_turn_stalled", + turnId: args.turnId, + ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + reason: args.reason, + message: args.message, + recoveryOptions, + sourceSessionId: managed.session.id, + ...(managed.session.orchestrationParentSessionId ? { parentSessionId: managed.session.orchestrationParentSessionId } : {}), + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message: args.message, + turnId: args.turnId, + }); + const parentSessionId = managed.session.orchestrationParentSessionId; + if (parentSessionId && parentSessionId !== managed.session.id) { + try { + const parent = managedSessions.get(parentSessionId) ?? (sessionService.get(parentSessionId) ? ensureManagedSession(parentSessionId) : null); + if (parent) { + const childLabel = managed.preview?.trim() || managed.session.id; + emitChatEvent(parent, { + type: "codex_turn_stalled", + turnId: args.turnId, + ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + reason: args.reason, + message: args.message, + recoveryOptions, + sourceSessionId: managed.session.id, + parentSessionId, + }); + emitChatEvent(parent, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message: `Child Codex session '${childLabel}' stalled: ${args.message}`, + turnId: args.turnId, + }); + persistChatState(parent); + } + } catch (error) { + logger.warn("agent_chat.codex_stall_parent_notice_failed", { + sessionId: managed.session.id, + parentSessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + persistChatState(managed); + } + + async function finishCodexTurnFromReconciledState( + managed: ManagedChatSession, + runtime: CodexRuntime, + turn: Record, + fallbackTurnId: string, + ): Promise { + const turnId = stringOrNull(turn.id) ?? fallbackTurnId; + rememberTerminalCodexTurn(runtime, turnId, managed); + runtime.awaitingTurnStart = false; + runtime.canAttachResumedTurnStart = false; + runtime.activeTurnId = null; + runtime.startedTurnId = null; + runtime.pendingTurnPlanningApprovalGuarded = null; + runtime.ignoredTurnIds.delete(turnId); + resetAssistantMessageStream(managed); + const status = mapCodexTurnStatus(turn.status); + if (status === "completed") { + for (const [planItemId, planText] of runtime.planTextByItemId) { + const planTurnId = runtime.itemTurnIdByItemId.get(planItemId) ?? turnId; + emitCodexPlanComplete(managed, planText, planTurnId, planItemId); + emitCodexPlanTextApproval(managed, runtime, planText, planTurnId); + } + } + runtime.planTextByItemId.clear(); + runtime.webSearchActionsByItemId.clear(); + runtime.itemTurnIdByItemId.clear(); + runtime.agentMessageScopeByTurn.clear(); + runtime.codexAgentIndexByTurn.delete(turnId); + runtime.agentMessageTextByTurn.clear(); + runtime.recentNotificationKeys.clear(); + runtime.reconciledItemSignaturesByTurn.delete(turnId); + const usage = normalizeUsagePayload(turn.usage ?? turn.totalUsage); + markSessionIdleWithFreshCache(managed); + drainPendingPlanFollowups(managed, runtime); + for (const [approvalId, pending] of runtime.approvals) { + if (pending.kind !== "plan_approval") { + runtime.approvals.delete(approvalId); + } + } + + const error = asRecord(turn.error); + const errorMessage = stringOrNull(error?.message); + if (status === "failed" && errorMessage) { + emitChatEvent(managed, { + type: "error", + message: errorMessage, + turnId, + errorInfo: formatCodexErrorInfo(error?.codexErrorInfo), + }); + } + + emitChatEvent(managed, { + type: "status", + turnStatus: status, + turnId, + ...(status === "failed" && errorMessage ? { message: errorMessage } : {}), + }); + stopActiveCodexSubagents( + managed, + runtime, + turnId, + status === "failed" + ? "Parent turn failed before ADE received a final subagent status" + : "Parent turn completed before ADE received a final subagent status", + { includeBackground: false }, + ); + void emitTurnDiffSummaryIfChanged(managed, turnId); + emitChatEvent(managed, { + type: "done", + turnId, + status, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + ...(usage ? { usage } : {}), + }); + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); + if (endSha) { + sessionService.setHeadShaEnd(managed.session.id, endSha); + } + persistChatState(managed); + } + + function isCodexSilentTurnStillCurrent( + managed: ManagedChatSession, + runtime: CodexRuntime, + turnId: string, + ): boolean { + return !managed.deleted + && !managed.closed + && managed.runtime === runtime + && !isTerminalCodexTurn(runtime, turnId, managed) + && (runtime.activeTurnId ?? runtime.startedTurnId) === turnId; + } + + async function reconcileCodexSilentTurn( + managed: ManagedChatSession, + runtime: CodexRuntime, + turnId: string, + ): Promise { + if (runtime.stallReconcileInFlight.has(turnId)) return; + runtime.stallReconcileInFlight.add(turnId); + try { + if (!isCodexSilentTurnStillCurrent(managed, runtime, turnId)) { + return; + } + const threadId = managed.session.threadId?.trim(); + let activeFlags: string[] = []; + let stateProbeFailed = false; + if (threadId) { + try { + const threadRead = await runtime.request("thread/read", { + threadId, + includeTurns: false, + }, { timeoutMs: CODEX_STALL_RECONCILE_TIMEOUT_MS }); + activeFlags = activeFlagsFromThreadReadResponse(threadRead); + } catch (error) { + stateProbeFailed = true; + logger.warn("agent_chat.codex_stall_thread_read_failed", { + sessionId: managed.session.id, + threadId, + turnId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const turnsResponse = threadId + ? await runtime.request<{ data?: unknown }>("thread/turns/list", { + threadId, + itemsView: "full", + limit: 5, + }, { timeoutMs: CODEX_STALL_RECONCILE_TIMEOUT_MS }).catch((error) => { + stateProbeFailed = true; + logger.warn("agent_chat.codex_stall_turns_list_failed", { + sessionId: managed.session.id, + threadId, + turnId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + }) + : null; + const turns = Array.isArray(turnsResponse?.data) + ? turnsResponse.data.filter((entry): entry is Record => + Boolean(entry && typeof entry === "object" && !Array.isArray(entry)) + ) + : []; + const currentTurn = turns.find((turn) => stringOrNull(turn.id) === turnId) ?? null; + if (currentTurn) { + if (!isCodexSilentTurnStillCurrent(managed, runtime, turnId)) { + return; + } + const items = codexTurnItems(currentTurn); + let recoveredUsefulItem = false; + items.forEach((item, index) => { + recoveredUsefulItem = emitCodexReconciledItem(managed, runtime, item, index, turnId) || recoveredUsefulItem; + }); + if (isTerminalCodexTurnStatusValue(currentTurn.status)) { + await finishCodexTurnFromReconciledState(managed, runtime, currentTurn, turnId); + return; + } + if (recoveredUsefulItem) { + logger.info("agent_chat.codex_stall_recovered_from_turns_list", { + sessionId: managed.session.id, + threadId, + turnId, + itemCount: items.length, + }); + persistChatState(managed); + scheduleCodexNoFirstEventWatchdog(managed, runtime, turnId); + return; + } + } + + if (!isCodexSilentTurnStillCurrent(managed, runtime, turnId)) { + return; + } + + if (activeFlags.includes("waitingOnApproval")) { + emitCodexTurnStalled(managed, runtime, { + turnId, + reason: "waiting_on_approval", + message: "Codex is waiting for an approval decision, but ADE did not receive a visible approval item yet. You can keep waiting, send a status nudge, or interrupt and retry this thread.", + }); + return; + } + if (activeFlags.includes("waitingOnUserInput")) { + emitCodexTurnStalled(managed, runtime, { + turnId, + reason: "waiting_on_input", + message: "Codex is waiting for user input, but ADE did not receive a visible input request yet. You can answer with a status nudge, keep waiting, or interrupt and retry this thread.", + }); + return; + } + + emitCodexTurnStalled(managed, runtime, { + turnId, + reason: threadId && !stateProbeFailed ? "no_output" : "app_server_state_unknown", + message: "Codex accepted this turn but has not streamed model or tool output yet. You can keep waiting, send a status nudge, or interrupt and retry this thread if it stays stalled.", + }); + } finally { + runtime.stallReconcileInFlight.delete(turnId); + } + } + const handleCodexNotification = async (managed: ManagedChatSession, runtime: CodexRuntime, payload: JsonRpcEnvelope): Promise => { const method = typeof payload.method === "string" ? payload.method : ""; const params = (payload.params as Record | null) ?? {}; @@ -16519,6 +17208,10 @@ export function createAgentChatService(args: { return; } + if (isCodexUsefulProgressNotification(method, params)) { + markCodexTurnProgress(runtime, turnIdFromParams); + } + if (method === "turn/started") { const turn = startedTurn; const turnId = typeof turn?.id === "string" ? turn.id : null; @@ -16548,7 +17241,9 @@ export function createAgentChatService(args: { runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); + runtime.reconciledItemSignaturesByTurn.clear(); setSessionActive(managed); + scheduleCodexNoFirstEventWatchdog(managed, runtime, turnId); if (!turnId || runtime.startedTurnId !== turnId) { runtime.startedTurnId = turnId; emitChatEvent(managed, { @@ -16616,6 +17311,7 @@ export function createAgentChatService(args: { runtime.codexAgentIndexByTurn.delete(turnId); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); + runtime.reconciledItemSignaturesByTurn.delete(turnId); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); markSessionIdleWithFreshCache(managed); drainPendingPlanFollowups(managed, runtime); @@ -16905,6 +17601,7 @@ export function createAgentChatService(args: { runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); + runtime.reconciledItemSignaturesByTurn.clear(); for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { itemId: followup.itemId, @@ -16988,6 +17685,11 @@ export function createAgentChatService(args: { return; } + if (method === "mcpServer/startupStatus/updated") { + handleCodexMcpStartupStatus(managed, runtime, params); + return; + } + if ( method === "thread/status/changed" || method === "codex/event/task_started" @@ -17223,6 +17925,11 @@ export function createAgentChatService(args: { agentMessageScopeByTurn: new Map(), agentMessageTextByTurn: new Map(), recentNotificationKeys: new Set(), + reconciledItemSignaturesByTurn: new Map>(), + noFirstEventWatchdog: null, + stalledTurnIds: new Set(), + stallReconcileInFlight: new Set(), + mcpStartupNoticeKeys: new Set(), pendingPlanFollowups: [], slashCommands: [], rateLimits: null, @@ -17556,6 +18263,7 @@ export function createAgentChatService(args: { runtime.threadResumed = true; runtime.canAttachResumedTurnStart = false; persistChatState(managed); + await seedCodexThreadGoalFromSessionGoal(managed, runtime); // Fetch available skills and populate slash commands. runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) @@ -18810,6 +19518,7 @@ export function createAgentChatService(args: { automationRunId, requestedCwd, runtimeMode, + goal: requestedGoal, orchestrationRunId: requestedOrchestrationRunId, orchestrationRole: requestedOrchestrationRole, orchestrationParentSessionId: requestedOrchestrationParentSessionId, @@ -19011,7 +19720,10 @@ export function createAgentChatService(args: { ? normalizePersistedOutputStyle(requestedClaudeOutputStyle) ?? readClaudeOutputStyleSelection(launchContext.laneWorktreePath) : null; - const normalizedTitle = typeof title === "string" ? title.trim() : ""; + const normalizedGoal = typeof requestedGoal === "string" && requestedGoal.trim().length + ? requestedGoal.trim() + : null; + const normalizedTitle = typeof title === "string" ? title.trim() : ""; const initialTitle = normalizedTitle || defaultChatSessionTitle(effectiveProvider); sessionService.create({ @@ -19030,6 +19742,7 @@ export function createAgentChatService(args: { chatSessionId: sessionId, ownerPid: processRegistry?.pid ?? null, ownerProcessStartedAt: processRegistry?.startedAt ?? null, + goal: normalizedGoal, }); if (normalizedTitle.length > 0) { sessionService.updateMeta({ sessionId, title: initialTitle, manuallyNamed: true }); @@ -19057,6 +19770,7 @@ export function createAgentChatService(args: { automationRunId: automationRunId?.trim() ? automationRunId.trim() : null, capabilityMode, completion: null, + ...(normalizedGoal ? { goal: normalizedGoal } : {}), status: "idle", idleSinceAt: null, createdAt: startedAt, @@ -19257,6 +19971,7 @@ export function createAgentChatService(args: { ?? trimLine(sourceSession.summary) ?? trimLine(sourceSession.title); if (inheritedGoal) { + createdManaged.session.goal = inheritedGoal; sessionService.updateMeta({ sessionId: created.id, goal: inheritedGoal, @@ -19529,6 +20244,7 @@ export function createAgentChatService(args: { managed.runtime.agentMessageScopeByTurn.clear(); managed.runtime.agentMessageTextByTurn.clear(); managed.runtime.recentNotificationKeys.clear(); + managed.runtime.reconciledItemSignaturesByTurn.clear(); if (isCodexRequestTimeoutError(error)) { teardownRuntime(managed, "handle_close"); } diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 807134eb0..dff2358ce 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -65,6 +65,29 @@ afterEach(async () => { }); describe("sessionService resume metadata", () => { + it("stores a create-time goal on the terminal session row", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-goal", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex chat", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-goal.log", + toolType: "codex-chat", + goal: "Run quality, tests, ship, merge, and release.", + }); + + expect(service.get("session-goal")?.goal).toBe("Run quality, tests, ship, merge, and release."); + }); + it("derives permission-aware resume commands from stored metadata", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 751a413a1..578de9a6c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -864,6 +864,7 @@ export function createSessionService({ db }: { db: AdeDb }) { chatSessionId, ownerPid, ownerProcessStartedAt, + goal, }: { sessionId: string; laneId: string; @@ -878,6 +879,7 @@ export function createSessionService({ db }: { db: AdeDb }) { chatSessionId?: string | null; ownerPid?: number | null; ownerProcessStartedAt?: string | null; + goal?: string | null; }): void { const normalizedToolType = normalizeToolType(toolType); const normalizedMetadata = normalizeResumeMetadata(resumeMetadata); @@ -889,12 +891,15 @@ export function createSessionService({ db }: { db: AdeDb }) { : null; const normalizedOwnerPid = normalizeOwnerPid(ownerPid); const normalizedOwnerProcessStartedAt = normalizeOwnerProcessStartedAt(ownerProcessStartedAt); + const normalizedGoal = typeof goal === "string" && goal.trim().length + ? goal.trim() + : null; db.run( ` insert into terminal_sessions( id, lane_id, pty_id, tracked, title, started_at, ended_at, exit_code, transcript_path, - head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, tool_type, resume_command, resume_metadata_json, chat_session_id, owner_pid, owner_process_started_at - ) values (?, ?, ?, ?, ?, ?, null, null, ?, null, null, 'running', null, null, null, ?, ?, ?, ?, ?, ?) + head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, tool_type, resume_command, resume_metadata_json, chat_session_id, owner_pid, owner_process_started_at, goal + ) values (?, ?, ?, ?, ?, ?, null, null, ?, null, null, 'running', null, null, null, ?, ?, ?, ?, ?, ?, ?) `, [ sessionId, @@ -910,6 +915,7 @@ export function createSessionService({ db }: { db: AdeDb }) { normalizedChatSessionId, normalizedOwnerPid, normalizedOwnerProcessStartedAt, + normalizedGoal, ] ); emitChanged({ sessionId, reason: "created" }); diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index b0d8bc24a..66908dd39 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -565,7 +565,7 @@ export function appendCollapsedChatTranscriptEvent( ): void { const { event } = envelope; - if (event.type === "step_boundary" || event.type === "activity" || event.type === "pending_input_resolved") { + if (event.type === "step_boundary" || event.type === "activity" || event.type === "pending_input_resolved" || event.type === "codex_turn_stalled") { return; } diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 1d2a2dbd4..31c622d8f 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -756,6 +756,16 @@ export type AgentChatEvent = usage: CodexThreadTokenUsage; turnId?: string; } + | { + type: "codex_turn_stalled"; + turnId: string; + threadId?: string; + reason: "no_output" | "waiting_on_input" | "waiting_on_approval" | "app_server_state_unknown"; + message: string; + recoveryOptions?: Array<"wait" | "steer" | "interrupt_retry_same_thread" | "restart_resume_thread">; + sourceSessionId?: string; + parentSessionId?: string; + } | { type: "codex_goal_updated"; goal: CodexThreadGoal | null; @@ -937,6 +947,7 @@ export type AgentChatSession = { model: string; modelId?: ModelId; sessionProfile?: AgentChatSessionProfile; + goal?: string | null; reasoningEffort?: string | null; fastMode?: boolean; /** Effective service tier reported by the Codex app-server, when known. */ diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 6df46da3d..49759a146 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -353,6 +353,13 @@ See the detail docs for the specifics: 4. The runtime streams events through the main-process event emitter and into the renderer via `ade.agentChat.event` (a push channel owned by `registerIpc.ts`). + Codex turns also run a narrow no-first-output watchdog: if `turn/start` + succeeds but no useful model/tool event arrives, ADE reconciles the same + app-server thread with `thread/read` and `thread/turns/list` before + surfacing a `codex_turn_stalled` event plus one visible `system_notice`. + The watchdog never auto-handoffs or interrupts; parent/orchestrator + sessions receive the structured stall event and decide whether to wait, + steer, interrupt, or retry the same thread. 5. On completion the service emits `status: "completed" | "failed" | "interrupted"`, optionally emits a `turn_diff_summary`, flushes buffered text, and pulls the next queued steer. @@ -506,6 +513,11 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. guard: when `getRecentEntries` is called, the service flushes pending buffered text first so transcript reads always reflect the latest streamed content. +- **Codex silent-turn recovery.** MCP startup status notifications are + warnings, not model progress. Do not let them clear the no-first-output + watchdog. If app-server state can be read, recovered turn items are + backfilled into the transcript and terminal turn state is finalized; only + a genuinely silent or unreadable turn emits `codex_turn_stalled`. - **Transcript read merges streaming text fragments.** The `MAX_TRANSCRIPT_READ_CHARS` budget is `120_000` (was `40_000`) and the transcript reader collapses consecutive assistant text events