diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e7a500710..1eca14ac7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { generateText, streamText } from "ai"; -import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk"; +import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- @@ -223,7 +223,8 @@ import { runGit } from "../git/git"; import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; -import type { AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; +import { mapPermissionToClaude } from "../orchestrator/permissionMapping"; +import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; // --------------------------------------------------------------------------- // Helpers @@ -1949,6 +1950,92 @@ describe("createAgentChatService", () => { expect(updated.computerUse!.mode).toBe("enabled"); }); + + it("manuallyNamed suppresses auto-titling after sendMessage", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Done" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + } as any); + + // Mock generateText so auto-title would produce a different name if called + vi.mocked(generateText).mockResolvedValue({ + text: "Auto Generated Title", + } as any); + + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + // Set the title manually with manuallyNamed flag + await service.updateSession({ + sessionId: session.id, + title: "My Title", + manuallyNamed: true, + }); + + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: session.id, title: "My Title" }), + ); + + // Send a message — this would normally trigger auto-titling + await service.sendMessage({ + sessionId: session.id, + text: "Build me a new feature", + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done", + ); + + // Give auto-title a chance to fire (it's a void promise) + await new Promise((resolve) => setTimeout(resolve, 50)); + + // generateText should NOT have been called for auto-titling because + // manuallyNamed suppresses it. (generateText is only used for auto-titling.) + expect(generateText).not.toHaveBeenCalled(); + }); }); // -------------------------------------------------------------------------- @@ -2673,6 +2760,24 @@ describe("createAgentChatService", () => { service.warmupModel({ sessionId: session.id, modelId: "anthropic/claude-sonnet-4-6-api" }), ).resolves.toBeUndefined(); }); + + it("does not rewrite a live session when the requested model does not match the backend session", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await expect( + service.warmupModel({ sessionId: session.id, modelId: "anthropic/claude-sonnet-4-6" }), + ).resolves.toBeUndefined(); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.provider).toBe("unified"); + expect(summary?.modelId).toBe("anthropic/claude-sonnet-4-6-api"); + }); }); // -------------------------------------------------------------------------- @@ -3221,6 +3326,262 @@ describe("createAgentChatService", () => { }), ).rejects.toThrow(/not found/i); }); + + it("cancelSteer removes a queued steer and emits a system_notice", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let interruptedTurnClosed = false; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + // init stream + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + if (streamCall === 2) { + // The blocking turn — yields an assistant message then waits + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Still working" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + while (!interruptedTurnClosed) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return; + } + + // streamCall >= 3: any follow-up turn — should NOT happen because the steer was cancelled + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Follow up" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + const mockSession = { + send, + stream, + close: vi.fn(() => { + interruptedTurnClosed = true; + }), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(mockSession as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(mockSession as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + // Start a turn so the runtime is busy + const activeTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Do some work", + timeoutMs: 15_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + // Queue a steer — runtime is busy so it should be queued + await service.steer({ sessionId: session.id, text: "queued steer text" }); + + // Find the queued user_message event to get the steerId + const queuedEvent = events.find( + (e) => + e.event.type === "user_message" + && (e.event as any).deliveryState === "queued" + && (e.event as any).text === "queued steer text", + ); + expect(queuedEvent).toBeDefined(); + const steerId = (queuedEvent!.event as any).steerId as string; + expect(steerId).toBeTruthy(); + + // Cancel the steer + await service.cancelSteer({ sessionId: session.id, steerId }); + + // Verify a system_notice with "Queued message cancelled." was emitted + const cancelNotice = events.find( + (e) => + e.event.type === "system_notice" + && (e.event as any).message === "Queued message cancelled.", + ); + expect(cancelNotice).toBeDefined(); + + // Interrupt the turn to let it complete + await service.interrupt({ sessionId: session.id }); + await activeTurn; + + // The cancelled steer should NOT have been delivered — `send` should not have been + // called with "queued steer text" + const sendCalls = send.mock.calls.map((c: any[]) => c[0]); + const deliveredSteer = sendCalls.find( + (arg: any) => + (typeof arg === "string" && arg.includes("queued steer text")) + || (typeof arg === "object" && JSON.stringify(arg).includes("queued steer text")), + ); + expect(deliveredSteer).toBeUndefined(); + }); + + it("editSteer updates the queued steer text and cancels on interrupt", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let interruptedTurnClosed = false; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + if (streamCall === 2) { + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Still working" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + while (!interruptedTurnClosed) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return; + } + + // streamCall >= 3: follow-up turn after steer delivery + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Responding to updated text" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + const mockSession = { + send, + stream, + close: vi.fn(() => { + interruptedTurnClosed = true; + }), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(mockSession as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(mockSession as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + // Start a turn so the runtime is busy + const activeTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Do some work", + timeoutMs: 15_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + // Queue a steer + await service.steer({ sessionId: session.id, text: "original steer text" }); + + // Get the steerId from the queued user_message event + const queuedEvent = events.find( + (e) => + e.event.type === "user_message" + && (e.event as any).deliveryState === "queued" + && (e.event as any).text === "original steer text", + ); + expect(queuedEvent).toBeDefined(); + const steerId = (queuedEvent!.event as any).steerId as string; + expect(steerId).toBeTruthy(); + + // Edit the steer + await service.editSteer({ sessionId: session.id, steerId, text: "updated text" }); + + // Verify a user_message with updated text and deliveryState "queued" was emitted + const editedEvent = events.find( + (e) => + e.event.type === "user_message" + && (e.event as any).deliveryState === "queued" + && (e.event as any).text === "updated text" + && (e.event as any).steerId === steerId, + ); + expect(editedEvent).toBeDefined(); + + // Interrupt the turn — queued steers should be cancelled, not delivered + await service.interrupt({ sessionId: session.id }); + await activeTurn; + + // Wait for the cancellation notice for the queued steer + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "system_notice" + && (event.event as any).steerId === steerId + && /cancelled/i.test((event.event as any).message), + ); + + // The steer should NOT have been delivered via send + const sendCalls = send.mock.calls.map((c: any[]) => c[0]); + const deliveredWithUpdatedText = sendCalls.find( + (arg: any) => + (typeof arg === "string" && arg.includes("updated text")) + || (typeof arg === "object" && JSON.stringify(arg).includes("updated text")), + ); + expect(deliveredWithUpdatedText).toBeUndefined(); + }); }); // -------------------------------------------------------------------------- @@ -3239,6 +3600,71 @@ describe("createAgentChatService", () => { ).rejects.toThrow(/not found/i); }); + it("gracefully handles missing Claude approval without throwing", async () => { + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-missing-approval", + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Done" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-missing-approval", + setPermissionMode, + } as any); + + const { service, logger } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + // Run a turn so the Claude runtime gets created + await service.runSessionTurn({ + sessionId: session.id, + text: "Hello", + }); + + // Call approveToolUse with a non-existent itemId — should NOT throw + await service.approveToolUse({ + sessionId: session.id, + itemId: "nonexistent-item-id", + decision: "accept", + }); + + expect(logger.warn).toHaveBeenCalledWith( + "agent_chat.claude_approval_not_found", + expect.objectContaining({ + sessionId: session.id, + itemId: "nonexistent-item-id", + decision: "accept", + }), + ); + }); + it("exits unified plan mode after a one-time plan approval", async () => { const events: AgentChatEventEnvelope[] = []; let requestApproval: diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8c629b936..c2cdc86a7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -264,7 +264,7 @@ type ClaudeRuntime = { slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; busy: boolean; activeTurnId: string | null; - pendingSteers: string[]; + pendingSteers: Array<{ steerId: string; text: string }>; approvals: Map; interrupted: boolean; /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ @@ -291,7 +291,7 @@ type UnifiedRuntime = { permissionMode: PermissionMode; pendingApprovals: Map; approvalOverrides: Set<"bash" | "write" | "exitPlanMode">; - pendingSteers: string[]; + pendingSteers: Array<{ steerId: string; text: string }>; interrupted: boolean; resolvedModel: LanguageModel; modelDescriptor: ModelDescriptor; @@ -687,6 +687,7 @@ type ResolvedChatConfig = { }; const MAX_PENDING_STEERS = 10; +const CLAUDE_WARMUP_WAIT_TIMEOUT_MS = 20_000; const DEFAULT_CODEX_DESCRIPTOR = getDefaultModelDescriptor("codex"); const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); @@ -707,6 +708,7 @@ const AUTO_TITLE_MAX_CHARS = 48; const REASONING_ACTIVITY_DETAIL = "Thinking through the answer"; const WORKING_ACTIVITY_DETAIL = "Preparing response"; const TURN_TIMEOUT_MS = 300_000; // 5 minutes – overall turn-level timeout +const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 75_000; const AUTO_TITLE_SYSTEM_PROMPT = `You title software development chat sessions. Return only the title text. - Use 2 to 6 words. @@ -1008,6 +1010,33 @@ function parseJsonLine(raw: string): JsonRpcEnvelope | null { } } +function resolveClaudeCliModelIdFromRuntimeValue(model: string): string | undefined { + const normalized = model.trim().toLowerCase(); + if (!normalized.length) return undefined; + + const normalizedWithoutProvider = normalized + .replace(/^anthropic\//, "") + .replace(/-api$/, ""); + + const inputs = [normalized, normalizedWithoutProvider]; + + return listModelDescriptorsForProvider("claude").find((descriptor) => { + const descriptorShortId = descriptor.shortId.toLowerCase(); + const candidates = new Set([ + descriptor.id.toLowerCase(), + descriptorShortId, + descriptor.sdkModelId.toLowerCase(), + descriptor.id.toLowerCase().replace(/^anthropic\//, ""), + ]); + + if (inputs.some((input) => candidates.has(input))) return true; + + return normalizedWithoutProvider === `claude-${descriptorShortId}` + || normalizedWithoutProvider.startsWith(`claude-${descriptorShortId}-`) + || normalizedWithoutProvider.includes(descriptorShortId); + })?.id; +} + function resolveModelIdFromStoredValue( model: string, providerHint?: AgentChatProvider, @@ -1015,6 +1044,11 @@ function resolveModelIdFromStoredValue( const normalized = model.trim().toLowerCase(); if (!normalized.length) return undefined; + if (providerHint === "claude") { + const resolvedClaudeCliModelId = resolveClaudeCliModelIdFromRuntimeValue(normalized); + if (resolvedClaudeCliModelId) return resolvedClaudeCliModelId; + } + const aliasMatch = resolveModelAlias(normalized); if (aliasMatch) { if (providerHint === "codex" && !(aliasMatch.family === "openai" && aliasMatch.isCliWrapped)) return undefined; @@ -1043,6 +1077,48 @@ function resolveModelIdFromStoredValue( return preferred?.id ?? matches[0]?.id; } +function normalizeReportedModelName(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length ? normalized : null; +} + +function extractReportedModelUsageNames(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + return Object.keys(value as Record) + .map(normalizeReportedModelName) + .filter((name): name is string => name !== null); +} + +function resolveClaudeTurnModelPayload( + session: Pick, + candidates: Array, +): { model: string; modelId?: string } { + for (const candidate of candidates) { + const normalized = normalizeReportedModelName(candidate); + if (!normalized) continue; + const normalizedCliModel = resolveClaudeCliModel(normalized); + const resolvedCliModelId = + resolveClaudeCliModelIdFromRuntimeValue(normalized) + ?? resolveClaudeCliModelIdFromRuntimeValue(normalizedCliModel); + if (resolvedCliModelId) { + return { model: normalized, modelId: resolvedCliModelId }; + } + const resolvedModelId = + resolveModelIdFromStoredValue(normalized, "claude") + ?? resolveModelIdFromStoredValue(normalizedCliModel, "claude"); + if (resolvedModelId) { + return { model: normalized, modelId: resolvedModelId }; + } + return { model: normalized }; + } + + return { + model: session.model, + ...(session.modelId ? { modelId: session.modelId } : {}), + }; +} + function fallbackModelForProvider(provider: AgentChatProvider): string { if (provider === "codex") return DEFAULT_CODEX_MODEL; if (provider === "claude") return DEFAULT_CLAUDE_MODEL; @@ -2145,6 +2221,20 @@ export function createAgentChatService(args: { // Intercept ExitPlanMode to show a plan approval UI instead of letting the // SDK handle it natively (which just collapses into the work log). if (toolName === "ExitPlanMode") { + // In bypass / full-auto mode, auto-approve the plan without showing + // approval UI — the user opted out of all permission gates. + const effectiveAccess = managed.session.claudePermissionMode ?? managed.session.permissionMode; + if (effectiveAccess === "bypassPermissions" || managed.session.permissionMode === "full-auto") { + // Transition out of plan mode so the UI reflects the change, + // matching the state update performed after manual approval. + if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { + managed.session.permissionMode = "edit"; + applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + persistChatState(managed); + } + return { behavior: "allow" }; + } + const inputRecord = (input && typeof input === "object" && !Array.isArray(input)) ? input as Record : {}; const planContent = typeof inputRecord.planDescription === "string" ? inputRecord.planDescription @@ -2183,7 +2273,7 @@ export function createAgentChatService(args: { emitPendingInputRequest(managed, request, { kind: "tool_call", - description: "Plan ready for approval", + description: planSummary, detail: { tool: "ExitPlanMode", planContent }, }); @@ -2199,6 +2289,12 @@ export function createAgentChatService(args: { const approved = response.decision === "accept" || response.decision === "accept_for_session"; if (approved) { + // Switch session out of plan mode so the UI reflects the transition. + if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { + managed.session.permissionMode = "edit"; + applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + persistChatState(managed); + } // Allow the tool — the SDK will process ExitPlanMode normally and // Claude will receive the standard "plan approved" tool result. return { behavior: "allow" }; @@ -4686,6 +4782,9 @@ export function createAgentChatService(args: { let assistantText = ""; let usage: { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheCreationTokens?: number | null } | undefined; let costUsd: number | null = null; + let reportedAssistantModel: string | null = null; + let reportedInitModel: string | null = null; + const reportedUsageModels = new Set(); const turnStartedAt = Date.now(); let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); @@ -4701,6 +4800,15 @@ export function createAgentChatService(args: { emittedClaudeTodoIds.add(itemId); emitChatEvent(managed, { type: "todo_update", items: todoItems, turnId }); }; + let turnTimeout: ReturnType | undefined; + let idleTimeout: ReturnType | undefined; + let timeoutError: Error | null = null; + const buildDoneModelPayload = (): { model: string; modelId?: string } => + resolveClaudeTurnModelPayload(managed.session, [ + reportedAssistantModel, + ...(reportedUsageModels.size === 1 ? [...reportedUsageModels] : []), + reportedInitModel, + ]); const markFirstStreamEvent = (kind: string): void => { if (firstStreamEventLogged) return; firstStreamEventLogged = true; @@ -4722,8 +4830,48 @@ export function createAgentChatService(args: { if (typeof contentIndex !== "number" || !Number.isFinite(contentIndex)) return undefined; return `claude-${kind}:${turnId}:${contentIndex}`; }; + const clearClaudeTurnTimers = (): void => { + if (turnTimeout) { + clearTimeout(turnTimeout); + turnTimeout = undefined; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = undefined; + } + }; + const failClaudeTurn = (message: string, reason: "timeout" | "idle"): void => { + if (timeoutError || runtime.interrupted) return; + timeoutError = new Error(message); + logger.warn("agent_chat.claude_turn_watchdog_fired", { + sessionId: managed.session.id, + turnId, + reason, + }); + cancelClaudeWarmup(managed, runtime, "timeout"); + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.sdkSessionId = null; + }; + const bumpClaudeIdleDeadline = (): void => { + if (idleTimeout) { + clearTimeout(idleTimeout); + } + idleTimeout = setTimeout(() => { + failClaudeTurn( + `Claude stopped streaming for ${Math.round(CLAUDE_STREAM_IDLE_TIMEOUT_MS / 1000)}s. The turn was reset so you can retry.`, + "idle", + ); + }, CLAUDE_STREAM_IDLE_TIMEOUT_MS); + }; try { + turnTimeout = setTimeout(() => { + failClaudeTurn( + `Claude turn exceeded ${Math.round(TURN_TIMEOUT_MS / 1000)}s. The runtime was reset so you can retry.`, + "timeout", + ); + }, TURN_TIMEOUT_MS); + const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); @@ -4759,18 +4907,9 @@ export function createAgentChatService(args: { } // ── V2 persistent session with background pre-warming ── // The pre-warm was kicked off in ensureClaudeSessionRuntime. Wait for it. - if (runtime.v2WarmupDone) { - const warmupWaitStartedAt = Date.now(); - logger.info("agent_chat.claude_v2_turn_waiting_for_warmup", { - sessionId: managed.session.id, - turnId, - }); - await runtime.v2WarmupDone; - logger.info("agent_chat.claude_v2_turn_warmup_wait_done", { - sessionId: managed.session.id, - turnId, - waitedMs: Date.now() - warmupWaitStartedAt, - }); + await waitForClaudeWarmup(managed, runtime, turnId); + if (timeoutError) { + throw timeoutError; } if (runtime.interrupted) { throw new Error("Claude turn interrupted during warmup."); @@ -4802,6 +4941,7 @@ export function createAgentChatService(args: { } // V2 pattern: send() then stream() per turn. Session stays alive between turns. + bumpClaudeIdleDeadline(); await runtime.v2Session.send(messageToSend); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); @@ -4810,6 +4950,10 @@ export function createAgentChatService(args: { for await (const msg of runtime.v2Session.stream()) { if (runtime.interrupted) break; + if (timeoutError) { + throw timeoutError; + } + bumpClaudeIdleDeadline(); markFirstStreamEvent(msg.type); // Capture session_id from any message @@ -4822,6 +4966,7 @@ export function createAgentChatService(args: { if (msg.type === "system" && (msg as any).subtype === "init") { const initMsg = msg as any; runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId; + reportedInitModel = normalizeReportedModelName(initMsg.model) ?? reportedInitModel; if (Array.isArray(initMsg.slash_commands)) { applyClaudeSlashCommands(runtime, initMsg.slash_commands); } @@ -5041,6 +5186,7 @@ export function createAgentChatService(args: { if (msg.type === "assistant") { const assistantMsg = msg as any; const betaMessage = assistantMsg.message; + reportedAssistantModel = normalizeReportedModelName(betaMessage?.model) ?? reportedAssistantModel; if (betaMessage?.content && Array.isArray(betaMessage.content)) { for (const [blockIndex, block] of betaMessage.content.entries()) { if (block.type === "text") { @@ -5281,6 +5427,9 @@ export function createAgentChatService(args: { // result — turn complete if (msg.type === "result") { const resultMsg = msg as any; + for (const modelName of extractReportedModelUsageNames(resultMsg.modelUsage)) { + reportedUsageModels.add(modelName); + } if (resultMsg.usage) { usage = { inputTokens: resultMsg.usage.input_tokens ?? null, @@ -5354,8 +5503,12 @@ export function createAgentChatService(args: { continue; } } + if (timeoutError) { + throw timeoutError; + } // ── Turn completion ── + clearClaudeTurnTimers(); // Note: v2Session is NOT closed here — it stays alive for the next turn runtime.activeQuery = null; runtime.busy = false; @@ -5373,14 +5526,14 @@ export function createAgentChatService(args: { runtime.v2WarmupDone = null; } + const doneModel = buildDoneModelPayload(); const finalStatus = runtime.interrupted ? "interrupted" : "completed"; emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); emitChatEvent(managed, { type: "done", turnId, status: finalStatus, - model: managed.session.model, - ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + ...doneModel, ...(usage ? { usage } : {}), ...(costUsd != null ? { costUsd } : {}), }); @@ -5400,21 +5553,11 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - if (!managed.closed && runtime.pendingSteers.length) { - const steerText = runtime.pendingSteers.shift() ?? ""; - if (steerText.trim().length) { - const preparedSteer = prepareSendMessage({ - sessionId: managed.session.id, - text: steerText, - displayText: steerText, - attachments: [], - }); - if (preparedSteer) { - await executePreparedSendMessage(preparedSteer); - } - } + if (runtime.pendingSteers.length) { + await deliverNextQueuedSteer(managed, runtime); } } catch (error) { + clearClaudeTurnTimers(); runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; @@ -5425,6 +5568,7 @@ export function createAgentChatService(args: { runtime.v2Session = null; runtime.v2StreamGen = null; runtime.v2WarmupDone = null; + const doneModel = buildDoneModelPayload(); if (runtime.interrupted) { managed.session.status = "idle"; @@ -5433,8 +5577,7 @@ export function createAgentChatService(args: { type: "done", turnId, status: "interrupted", - model: managed.session.model, - ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + ...doneModel, }); } else { managed.session.status = "idle"; @@ -5457,8 +5600,7 @@ export function createAgentChatService(args: { type: "done", turnId, status: "failed", - model: managed.session.model, - ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + ...doneModel, }); appendWorkerActivityToCto(managed, { @@ -5482,6 +5624,8 @@ export function createAgentChatService(args: { } persistChatState(managed); + cancelQueuedSteers(managed, runtime, runtime.interrupted ? "interrupted" : "failed"); + return; } }; @@ -6120,19 +6264,8 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - if (!managed.closed && runtime.pendingSteers.length) { - const steerText = runtime.pendingSteers.shift() ?? ""; - if (steerText.trim().length) { - const preparedSteer = prepareSendMessage({ - sessionId: managed.session.id, - text: steerText, - displayText: steerText, - attachments: [], - }); - if (preparedSteer) { - await executePreparedSendMessage(preparedSteer); - } - } + if (runtime.pendingSteers.length) { + await deliverNextQueuedSteer(managed, runtime); } } } catch (error) { @@ -6185,6 +6318,8 @@ export function createAgentChatService(args: { } persistChatState(managed); + cancelQueuedSteers(managed, runtime, runtime.interrupted ? "interrupted" : "failed"); + return; } }; @@ -7744,7 +7879,7 @@ export function createAgentChatService(args: { const cancelClaudeWarmup = ( managed: ManagedChatSession, runtime: ClaudeRuntime, - reason: "interrupt" | "teardown" | "session_reset", + reason: "interrupt" | "teardown" | "session_reset" | "timeout", ): void => { if (!runtime.v2WarmupDone) return; runtime.v2WarmupCancelled = true; @@ -7755,6 +7890,85 @@ export function createAgentChatService(args: { }); }; + const cancelQueuedSteers = ( + managed: ManagedChatSession, + runtime: Pick, + reason: "interrupted" | "failed" | "disposed", + ): void => { + const cancelled = runtime.pendingSteers.splice(0); + if (!cancelled.length) return; + + const cancelReasons: Record = { + interrupted: "Queued message cancelled because the current turn was interrupted.", + failed: "Queued message cancelled because the current turn failed.", + disposed: "Queued message cancelled because the session was closed.", + }; + const message = cancelReasons[reason]; + + for (const steer of cancelled) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId: steer.steerId, + message, + turnId: runtime.activeTurnId ?? undefined, + }); + } + }; + + const waitForClaudeWarmup = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + turnId: string, + ): Promise => { + if (!runtime.v2WarmupDone) return; + + const warmupWaitStartedAt = Date.now(); + logger.info("agent_chat.claude_v2_turn_waiting_for_warmup", { + sessionId: managed.session.id, + turnId, + }); + + let warmupTimeoutHandle: ReturnType | undefined; + try { + const warmupTimeout = new Promise<"timeout">((resolve) => { + warmupTimeoutHandle = setTimeout(() => resolve("timeout"), CLAUDE_WARMUP_WAIT_TIMEOUT_MS); + }); + const warmupState = await Promise.race([ + runtime.v2WarmupDone.then(() => "ready" as const), + warmupTimeout, + ]); + + if (warmupState === "timeout") { + logger.warn("agent_chat.claude_v2_turn_warmup_timeout", { + sessionId: managed.session.id, + turnId, + timeoutMs: CLAUDE_WARMUP_WAIT_TIMEOUT_MS, + }); + cancelClaudeWarmup(managed, runtime, "timeout"); + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.v2Session = null; + runtime.v2WarmupDone = null; + runtime.sdkSessionId = null; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Claude session warmup timed out. Restarting the session for this turn.", + turnId, + }); + return; + } + + logger.info("agent_chat.claude_v2_turn_warmup_wait_done", { + sessionId: managed.session.id, + turnId, + waitedMs: Date.now() - warmupWaitStartedAt, + }); + } finally { + if (warmupTimeoutHandle) clearTimeout(warmupTimeoutHandle); + } + }; + const applyClaudeSlashCommands = ( runtime: ClaudeRuntime, commands: Array, @@ -7780,6 +7994,105 @@ export function createAgentChatService(args: { .filter((command): command is { name: string; description: string; argumentHint?: string } => Boolean(command)); }; + const deliverNextQueuedSteer = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime | UnifiedRuntime, + ): Promise => { + if (managed.closed) return false; + + const nextSteer = runtime.pendingSteers.shift(); + if (!nextSteer) return false; + + const trimmed = nextSteer.text.trim(); + if (!trimmed.length) { + persistChatState(managed); + return false; + } + + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId: nextSteer.steerId, + message: "Delivering your queued message...", + turnId: runtime.activeTurnId ?? undefined, + }); + + runtime.interrupted = false; + persistChatState(managed); + + // Re-resolve lane context so that a lane switch that occurred while the + // steer was queued is reflected in the delivered prompt. + const executionContext = resolveManagedExecutionContext(managed, { + purpose: "deliver queued steer", + }); + const laneDirectiveKey = executionContext.laneDirectiveKey; + const shouldInjectLaneDirective = + laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; + const promptText = composeLaunchDirectives(trimmed, [ + shouldInjectLaneDirective + ? buildLaneWorktreeDirective({ + laneId: executionContext.laneId, + laneWorktreePath: executionContext.laneWorktreePath, + }) + : null, + ]); + + if (runtime.kind === "claude") { + await runClaudeTurn(managed, { + promptText, + displayText: trimmed, + attachments: [], + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, + }); + } else { + await runTurn(managed, { + promptText, + displayText: trimmed, + attachments: [], + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, + }); + } + + return true; + }; + + /** Enqueue a steer or drop it if the queue is full. Returns true if queued. */ + const enqueueSteerOrDrop = ( + managed: ManagedChatSession, + runtime: Pick, + sessionId: string, + steerId: string, + text: string, + ): boolean => { + if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { + logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: runtime.pendingSteers.length }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Steer dropped — the queue is full. Wait for the current turn to finish.", + turnId: runtime.activeTurnId ?? undefined, + }); + return false; + } + runtime.pendingSteers.push({ steerId, text }); + emitChatEvent(managed, { + type: "user_message", + text, + steerId, + turnId: runtime.activeTurnId ?? undefined, + deliveryState: "queued", + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: `Message queued (#${runtime.pendingSteers.length}) — will be sent after the current turn.`, + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + return true; + }; + /** * Pre-warm the Claude V2 session in the background. * Creates the persistent session and runs a silent warmup turn because the @@ -7860,6 +8173,7 @@ export function createAgentChatService(args: { } if (runtime.v2WarmupCancelled) { + // Warmup was cancelled during streaming — clean up and bail try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; return; @@ -8837,29 +9151,7 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "unified") { const runtime = managed.runtime; if (runtime.busy) { - if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { - logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: runtime.pendingSteers.length }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Steer dropped — the queue is full. Wait for the current turn to finish.", - turnId: runtime.activeTurnId ?? undefined, - }); - return; - } - runtime.pendingSteers.push(trimmed); - emitChatEvent(managed, { - type: "user_message", - text: trimmed, - turnId: runtime.activeTurnId ?? undefined, - }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Message queued — will be sent when the current turn completes.", - turnId: runtime.activeTurnId ?? undefined, - }); - persistChatState(managed); + enqueueSteerOrDrop(managed, runtime, sessionId, randomUUID(), trimmed); return; } const preparedSteer = prepareSendMessage({ @@ -8901,29 +9193,7 @@ export function createAgentChatService(args: { const runtime = ensureClaudeSessionRuntime(managed); if (runtime.busy) { - if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { - logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: runtime.pendingSteers.length }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Steer dropped — the queue is full. Wait for the current turn to finish.", - turnId: runtime.activeTurnId ?? undefined, - }); - return; - } - runtime.pendingSteers.push(trimmed); - emitChatEvent(managed, { - type: "user_message", - text: trimmed, - turnId: runtime.activeTurnId ?? undefined, - }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Message queued — will be sent when the current turn completes.", - turnId: runtime.activeTurnId ?? undefined, - }); - persistChatState(managed); + enqueueSteerOrDrop(managed, runtime, sessionId, randomUUID(), trimmed); return; } @@ -8937,12 +9207,57 @@ export function createAgentChatService(args: { await executePreparedSendMessage(preparedSteer); }; - const cancelSteer = async ({ sessionId }: AgentChatCancelSteerArgs): Promise => { - await interrupt({ sessionId }); + const cancelSteer = async ({ sessionId, steerId }: AgentChatCancelSteerArgs): Promise => { + const managed = ensureManagedSession(sessionId); + const runtime = managed.runtime; + if (!runtime || runtime.kind === "codex") return; + + const queue = runtime.pendingSteers; + const idx = queue.findIndex((s) => s.steerId === steerId); + if (idx === -1) return; + + queue.splice(idx, 1); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Queued message cancelled.", + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); }; - const editSteer = async ({ sessionId, text }: AgentChatEditSteerArgs): Promise => { - await steer({ sessionId, text }); + const editSteer = async ({ sessionId, steerId, text }: AgentChatEditSteerArgs): Promise => { + const trimmed = text.trim(); + const managed = ensureManagedSession(sessionId); + const runtime = managed.runtime; + if (!runtime || runtime.kind === "codex") return; + + const idx = runtime.pendingSteers.findIndex((s) => s.steerId === steerId); + if (idx === -1) return; + + if (!trimmed.length) { + runtime.pendingSteers.splice(idx, 1); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Queued message cancelled (empty edit).", + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + return; + } + + runtime.pendingSteers[idx].text = trimmed; + emitChatEvent(managed, { + type: "user_message", + text: trimmed, + steerId, + turnId: runtime.activeTurnId ?? undefined, + deliveryState: "queued", + }); + persistChatState(managed); }; const interrupt = async ({ sessionId }: AgentChatInterruptArgs): Promise => { @@ -8953,6 +9268,8 @@ export function createAgentChatService(args: { if (managed.runtime.interrupted) return; managed.runtime.interrupted = true; managed.runtime.abortController?.abort(); + cancelQueuedSteers(managed, managed.runtime, "interrupted"); + persistChatState(managed); for (const [itemId, approval] of managed.runtime.pendingApprovals) { approval.resolve({ decision: "decline" }); managed.runtime.pendingApprovals.delete(itemId); @@ -8984,12 +9301,14 @@ export function createAgentChatService(args: { // and breaks cleanly rather than throwing from a closed session. runtime.interrupted = true; cancelClaudeWarmup(managed, runtime, "interrupt"); + cancelQueuedSteers(managed, runtime, "interrupted"); runtime.activeQuery?.interrupt().catch(() => {}); // Drain pending approvals so their promises settle instead of hanging forever for (const pending of runtime.approvals.values()) { pending.resolve({ decision: "cancel" }); } runtime.approvals.clear(); + runtime.pendingElicitations.clear(); // Close the V2 session on interrupt — it will be recreated on the next turn try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; @@ -9414,7 +9733,15 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "claude") { const pending = managed.runtime.approvals.get(itemId); if (!pending) { - throw new Error(`No pending approval found for item '${itemId}'.`); + // The approval may have already been resolved (e.g. double-click, + // turn interrupted, or stale UI state). Log and return silently + // instead of throwing — the UI will clear the stale entry. + logger.warn("agent_chat.claude_approval_not_found", { + sessionId, + itemId, + decision, + }); + return; } managed.runtime.approvals.delete(itemId); pending.resolve({ decision, answers, responseText }); @@ -9533,6 +9860,7 @@ export function createAgentChatService(args: { // Mark streaming runtimes as interrupted so the catch block handles gracefully if (managed.runtime?.kind === "claude" || managed.runtime?.kind === "unified") { managed.runtime.interrupted = true; + cancelQueuedSteers(managed, managed.runtime, "disposed"); } await finishSession(managed, "disposed", { @@ -9808,15 +10136,18 @@ export function createAgentChatService(args: { const isAnthropicCli = descriptor.family === "anthropic" && descriptor.isCliWrapped; if (!isAnthropicCli) return; + // Warmup should never rewrite the live session model. It's only allowed to + // prime the currently-selected Claude runtime when the backend session is + // already aligned with the requested model and fully idle. + if (managed.session.provider !== "claude") return; + if (managed.session.modelId !== descriptor.id) return; + if (managed.session.status === "active") return; + if (managed.runtime && managed.runtime.kind !== "claude") return; + if (managed.runtime?.kind === "claude" && managed.runtime.busy) return; + // Only prewarm if the session is idle (not mid-turn) and not already warmed if (managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) return; - // Apply the selected model to the session so buildClaudeV2SessionOpts - // picks up the correct model for warmup. - managed.session.provider = "claude"; - managed.session.modelId = descriptor.id; - managed.session.model = descriptor.shortId; - // Ensure a Claude runtime exists and kick off pre-warming ensureClaudeSessionRuntime(managed); prewarmClaudeV2Session(managed); diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts index 040edded2..65c81aac3 100644 --- a/apps/desktop/src/main/services/context/contextDocBuilder.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.ts @@ -956,6 +956,7 @@ export async function runContextDocGeneration( const lastRunRaw = deps.db.getJson(CONTEXT_DOC_LAST_RUN_KEY); const lastGeneratedAt = typeof lastRunRaw?.generatedAt === "string" ? lastRunRaw.generatedAt : null; const persistedDocResults = readPersistedDocResults(lastRunRaw); + const generationStartedAt = nowIso(); const existingPrdFile = readContextDocFile(deps.projectRoot, ADE_DOC_PRD_REL); const existingArchFile = readContextDocFile(deps.projectRoot, ADE_DOC_ARCH_REL); const bundle = await buildHybridSourceBundle(deps.projectRoot, lastGeneratedAt); @@ -1179,9 +1180,8 @@ export async function runContextDocGeneration( }); } - const generatedAt = nowIso(); deps.db.setJson(CONTEXT_DOC_LAST_RUN_KEY, { - generatedAt, + generatedAt: generationStartedAt, provider, trigger, modelId, @@ -1208,7 +1208,7 @@ export async function runContextDocGeneration( return { provider, - generatedAt, + generatedAt: generationStartedAt, prdPath: prdWrite.writtenPath, architecturePath: archWrite.writtenPath, usedFallbackPath: prdWrite.usedFallback || archWrite.usedFallback, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index cc6c6a447..13da6c094 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3596,7 +3596,10 @@ export function registerIpc({ if (typeof record.sessionId !== "string" || !record.sessionId.trim()) { throw new Error("Agent chat cancel steer sessionId must be a non-empty string"); } - return { sessionId: record.sessionId }; + if (typeof record.steerId !== "string" || !record.steerId.trim()) { + throw new Error("Agent chat cancel steer steerId must be a non-empty string"); + } + return { sessionId: record.sessionId.trim(), steerId: record.steerId.trim() }; }; const parseAgentChatEditSteerArgs = ( @@ -3606,10 +3609,13 @@ export function registerIpc({ if (typeof record.sessionId !== "string" || !record.sessionId.trim()) { throw new Error("Agent chat edit steer sessionId must be a non-empty string"); } + if (typeof record.steerId !== "string" || !record.steerId.trim()) { + throw new Error("Agent chat edit steer steerId must be a non-empty string"); + } if (typeof record.text !== "string") { throw new Error("Agent chat edit steer text must be a string"); } - return { sessionId: record.sessionId, text: record.text }; + return { sessionId: record.sessionId.trim(), steerId: record.steerId.trim(), text: record.text }; }; ipcMain.handle(IPC.lanesOAuthGetStatus, async () => { diff --git a/apps/desktop/src/main/services/lanes/laneTemplateService.test.ts b/apps/desktop/src/main/services/lanes/laneTemplateService.test.ts index f2945e661..4703217ab 100644 --- a/apps/desktop/src/main/services/lanes/laneTemplateService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneTemplateService.test.ts @@ -443,4 +443,187 @@ describe("laneTemplateService", () => { }); }); }); + + // --------------------------------------------------------------- + // 4. Setup script resolution tests + // --------------------------------------------------------------- + + describe("resolveSetupScript", () => { + function makeService() { + const snapshot = makeSnapshot(); + const configService = makeProjectConfigService(snapshot); + return createLaneTemplateService({ + projectConfigService: configService, + logger, + }); + } + + it("returns null when template has no setupScript", () => { + const service = makeService(); + const template = makeTemplate({ id: "tpl-no-script", name: "No Script" }); + + const result = service.resolveSetupScript(template); + + expect(result).toBeNull(); + }); + + it("returns null when setupScript has empty commands and no scriptPath", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-empty-script", + name: "Empty Script", + setupScript: { commands: [] }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).toBeNull(); + }); + + it("uses unixCommands on non-Windows", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-unix", + name: "Unix Commands", + setupScript: { + commands: ["generic-cmd"], + unixCommands: ["bash setup.sh"], + windowsCommands: ["powershell setup.ps1"], + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.commands).toEqual(["bash setup.sh"]); + }); + + it("falls back to generic commands when no unixCommands provided", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-generic", + name: "Generic Commands", + setupScript: { + commands: ["npm run setup"], + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.commands).toEqual(["npm run setup"]); + }); + + it("uses unixScriptPath on non-Windows, falls back to generic scriptPath", () => { + const service = makeService(); + + const templateWithUnix = makeTemplate({ + id: "tpl-unix-path", + name: "Unix Script Path", + setupScript: { + scriptPath: "setup.sh", + unixScriptPath: "scripts/unix-setup.sh", + windowsScriptPath: "scripts/win-setup.ps1", + }, + }); + + const resultWithUnix = service.resolveSetupScript(templateWithUnix); + expect(resultWithUnix).not.toBeNull(); + expect(resultWithUnix!.scriptPath).toBe("scripts/unix-setup.sh"); + + const templateFallback = makeTemplate({ + id: "tpl-fallback-path", + name: "Fallback Script Path", + setupScript: { + scriptPath: "setup.sh", + }, + }); + + const resultFallback = service.resolveSetupScript(templateFallback); + expect(resultFallback).not.toBeNull(); + expect(resultFallback!.scriptPath).toBe("setup.sh"); + }); + + it("defaults injectPrimaryPath to false when not set", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-no-inject", + name: "No Inject", + setupScript: { + commands: ["echo hello"], + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.injectPrimaryPath).toBe(false); + }); + + it("returns injectPrimaryPath true when explicitly set", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-inject", + name: "With Inject", + setupScript: { + commands: ["echo hello"], + injectPrimaryPath: true, + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.injectPrimaryPath).toBe(true); + }); + + it("returns commands and scriptPath together", () => { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-both", + name: "Both", + setupScript: { + commands: ["npm install", "npm run build"], + scriptPath: "scripts/post-setup.sh", + injectPrimaryPath: true, + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.commands).toEqual(["npm install", "npm run build"]); + expect(result!.scriptPath).toBe("scripts/post-setup.sh"); + expect(result!.injectPrimaryPath).toBe(true); + }); + + it("uses windowsCommands and windowsScriptPath on win32", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + + try { + const service = makeService(); + const template = makeTemplate({ + id: "tpl-win", + name: "Windows", + setupScript: { + commands: ["generic-cmd"], + unixCommands: ["bash setup.sh"], + windowsCommands: ["powershell setup.ps1"], + scriptPath: "setup.sh", + unixScriptPath: "scripts/unix-setup.sh", + windowsScriptPath: "scripts/win-setup.ps1", + }, + }); + + const result = service.resolveSetupScript(template); + + expect(result).not.toBeNull(); + expect(result!.commands).toEqual(["powershell setup.ps1"]); + expect(result!.scriptPath).toBe("scripts/win-setup.ps1"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + } + }); + }); }); diff --git a/apps/desktop/src/main/services/lanes/laneTemplateService.ts b/apps/desktop/src/main/services/lanes/laneTemplateService.ts index 879163c19..c375d584f 100644 --- a/apps/desktop/src/main/services/lanes/laneTemplateService.ts +++ b/apps/desktop/src/main/services/lanes/laneTemplateService.ts @@ -7,6 +7,12 @@ import { NO_DEFAULT_LANE_TEMPLATE } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { createProjectConfigService } from "../config/projectConfigService"; +type ResolvedSetupScript = { + commands: string[]; + scriptPath?: string; + injectPrimaryPath: boolean; +}; + export function createLaneTemplateService({ projectConfigService, logger, @@ -58,6 +64,34 @@ export function createLaneTemplateService({ }; } + /** + * Resolves the platform-appropriate setup script commands from a template. + * Returns null if no setup script is configured. + */ + function resolveSetupScript(template: LaneTemplate): ResolvedSetupScript | null { + const cfg = template.setupScript; + if (!cfg) return null; + + const isWindows = process.platform === "win32"; + + // Platform-specific commands take precedence + const commands = isWindows + ? (cfg.windowsCommands ?? cfg.commands ?? []) + : (cfg.unixCommands ?? cfg.commands ?? []); + + const scriptPath = isWindows + ? (cfg.windowsScriptPath ?? cfg.scriptPath) + : (cfg.unixScriptPath ?? cfg.scriptPath); + + if (commands.length === 0 && !scriptPath) return null; + + return { + commands, + scriptPath, + injectPrimaryPath: cfg.injectPrimaryPath ?? false, + }; + } + function saveTemplate(template: LaneTemplate): void { const snapshot = projectConfigService.get(); const existing = [...(snapshot.local.laneTemplates ?? [])]; @@ -97,6 +131,7 @@ export function createLaneTemplateService({ getDefaultTemplateId, setDefaultTemplateId, resolveTemplateAsEnvInit, + resolveSetupScript, saveTemplate, deleteTemplate, }; diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index 5c94411b0..39f664941 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -2926,6 +2926,7 @@ export function createMissionService({ ...(args.recoveryLoop ? { recoveryLoop: args.recoveryLoop } : {}), ...(executionPolicyArg?.integrationPr ? { integrationPr: executionPolicyArg.integrationPr } : {}), ...(executionPolicyArg?.teamRuntime ? { teamRuntime: executionPolicyArg.teamRuntime } : {}), + prStrategy: executionPolicyArg?.prStrategy ?? { kind: "manual" }, finalizationPolicyKind: "result_lane", }; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index b1529daba..354c23ff7 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -6954,8 +6954,10 @@ Check all worker statuses and continue managing the mission from here. Read work } startupStage = "run_activate"; - const activatedRun = orchestratorService.activateRun(startedRunId); + // Persist the mission lane before activation so that any listeners + // of the run_activated event can already resolve the lane ID. persistMissionLaneIdForRun(startedRunId, missionLaneId); + const activatedRun = orchestratorService.activateRun(startedRunId); transitionMissionStatus(missionId, "in_progress"); if (initialPhase?.phaseKey.trim().toLowerCase() !== "planning") { emitOrchestratorMessage( diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 6289dc582..fcfea1920 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -5961,7 +5961,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act if (!phaseCanLoop && policy.maxQuestions != null && priorInterventions.length >= policy.maxQuestions) { return { ok: false as const, - error: `This Planning phase already reached its Ask Questions limit (${policy.maxQuestions}). Continue with the best grounded assumptions you can.`, + error: `This phase already reached its Ask Questions limit (${policy.maxQuestions}). Continue with the best grounded assumptions you can.`, }; } const firstQuestion = questions[0].question.trim(); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index 5e1e4f1f7..e43287718 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -240,6 +240,7 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { }; } + function upsertItem( prId: string, externalId: string, diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index fccd5918d..9218682fb 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -446,6 +446,7 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s ? formatIssueCommentsDetailed(args.issueComments) : formatIssueCommentsSummary(args.issueComments); + promptSections.push( "", "Current failing checks", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 2c58d9cc2..e2c3ed8e9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { At, Image, Paperclip, Square, X, PaperPlaneTilt, Cube, BookOpen } from "@phosphor-icons/react"; +import { At, CaretDown, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen } from "@phosphor-icons/react"; import { inferAttachmentType, type AgentChatApprovalDecision, @@ -130,7 +130,117 @@ const UNIFIED_PERMISSION_OPTIONS: Array<{ value: AgentChatUnifiedPermissionMode; { value: "full-auto", label: "Full auto" }, ]; +/** Inline display of a single pending (queued) steer message with cancel and edit controls. */ +function PendingSteerItem({ + steer, + onCancel, + onEdit, +}: { + steer: { steerId: string; text: string }; + onCancel: () => void; + onEdit: (text: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [editText, setEditText] = useState(steer.text); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [editing]); + + useEffect(() => { + if (!editing) { + setEditText(steer.text); + } + }, [editing, steer.text]); + function cancelEdit(): void { + setEditing(false); + setEditText(steer.text); + } + + function commitEdit(): void { + const trimmed = editText.trim(); + if (!trimmed.length) { + onCancel(); + return; + } + if (trimmed !== steer.text) { + onEdit(trimmed); + } + setEditing(false); + } + + return ( +
+
+ {editing ? ( +
+