diff --git a/.ade/ade.yaml b/.ade/ade.yaml index 8679ba36a..f07e43ded 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -1,5 +1,12 @@ version: 1 -processes: [] +processes: + - id: ad55deza + name: Dev + command: + - npm + - run + - dev + cwd: apps/desktop stackButtons: [] testSuites: [] laneOverlayPolicies: [] diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index e7a73393e..07f19bb9c 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,13 +1,6 @@ name: CTO -version: 1 -persona: >- - You are the CTO for this project inside ADE. - - You are the persistent technical lead who owns architecture, execution - quality, engineering continuity, and team direction. - - Use ADE's tools and project context to help the team move forward with clear, - concrete decisions. +version: 3 +persona: Persistent project CTO with strategic personality. personality: strategic modelPreferences: provider: claude @@ -28,4 +21,8 @@ openclawContextPolicy: - secret - token - system_prompt -updatedAt: 1970-01-01T00:00:00.000Z +onboardingState: + completedSteps: + - identity + completedAt: 2026-03-26T18:45:21.214Z +updatedAt: 2026-03-26T18:45:21.216Z diff --git a/.gitleaks.toml b/.gitleaks.toml index fcf083258..a06ecfec0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -9,4 +9,8 @@ paths = [ '''dist/''', '''release/''', '''\.git/''', + '''\.test\.ts$''', + '''\.test\.tsx$''', + '''\.spec\.ts$''', + '''\.spec\.tsx$''', ] diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index e0a80aa64..3b2fde49c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, nativeImage, shell } from "electron"; import { execSync } from "node:child_process"; import path from "node:path"; +type NodePtyType = typeof import("node-pty"); import { registerIpc } from "./services/ipc/registerIpc"; import { createFileLogger } from "./services/logging/logger"; import { openKvDb } from "./services/state/kvDb"; @@ -37,7 +38,7 @@ import { detectDefaultBaseRef, resolveRepoRoot, toProjectInfo, upsertProjectRow import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; -import type { ProjectInfo } from "../shared/types"; +import type { PortLease, ProjectInfo } from "../shared/types"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; import net from "node:net"; @@ -402,7 +403,7 @@ async function createWindow(logger?: Logger): Promise { err: toErrorMessage(error) }); const fallbackHtml = encodeURIComponent( - `` + + `` + `

ADE failed to load renderer

` + `

URL: ${rendererUrl.replace(/` + `

Error: ${toErrorMessage(error).replace(/` + @@ -498,7 +499,7 @@ app.whenReady().then(async () => { const loadPty = () => { // node-pty is a native dependency; keep the require inside the main process runtime. // eslint-disable-next-line @typescript-eslint/no-var-requires - return require("node-pty") as typeof import("node-pty"); + return require("node-pty") as NodePtyType; }; const normalizeProjectRoot = (projectRoot: string) => path.resolve(projectRoot); @@ -671,7 +672,7 @@ app.whenReady().then(async () => { logger, broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.lanesPortEvent, ev), persistLeases: (leases) => db.setJson("port_leases", leases), - loadLeases: () => db.getJson("port_leases") ?? [], + loadLeases: () => db.getJson("port_leases") ?? [], }); portAllocationService.restore(); @@ -1240,7 +1241,6 @@ app.whenReady().then(async () => { transcriptsDir: adePaths.transcriptsDir, projectId, memoryService, - memoryFilesService, fileService, workerAgentService, workerHeartbeatService, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 31e6a3283..4b14c7f57 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -32,6 +32,7 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo defaultLaneId: "lane-1", defaultModelId: "openai/gpt-5-chat-latest", defaultReasoningEffort: "medium", + resolveExecutionLane: vi.fn().mockResolvedValue("lane-1"), laneService: { list: vi.fn().mockResolvedValue([]), create: vi.fn(), @@ -66,308 +67,1239 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo } describe("createCtoOperatorTools", () => { - it("returns bounded chat transcript reads through the chat service helper", async () => { + // ── Tool set structure ────────────────────────────────────────── + + it("returns all expected operator tool keys", () => { const deps = buildDeps(); const tools = createCtoOperatorTools(deps); + const toolKeys = Object.keys(tools); - const result = await (tools.getChatTranscript as any).execute({ - sessionId: "chat-1", - limit: 5, - maxChars: 500, + // Core chat tools + expect(toolKeys).toContain("listChats"); + expect(toolKeys).toContain("spawnChat"); + expect(toolKeys).toContain("sendChatMessage"); + expect(toolKeys).toContain("interruptChat"); + expect(toolKeys).toContain("resumeChat"); + expect(toolKeys).toContain("endChat"); + expect(toolKeys).toContain("getChatStatus"); + expect(toolKeys).toContain("getChatTranscript"); + + // Lane tools + expect(toolKeys).toContain("listLanes"); + expect(toolKeys).toContain("inspectLane"); + expect(toolKeys).toContain("createLane"); + + // Linear workflow tools + expect(toolKeys).toContain("listLinearWorkflows"); + expect(toolKeys).toContain("getLinearRunStatus"); + expect(toolKeys).toContain("resolveLinearRunAction"); + expect(toolKeys).toContain("cancelLinearRun"); + expect(toolKeys).toContain("rerouteLinearRun"); + + // Mission tools + expect(toolKeys).toContain("listMissions"); + expect(toolKeys).toContain("startMission"); + expect(toolKeys).toContain("getMissionStatus"); + expect(toolKeys).toContain("updateMission"); + expect(toolKeys).toContain("launchMissionRun"); + expect(toolKeys).toContain("resolveMissionIntervention"); + expect(toolKeys).toContain("getMissionRunView"); + expect(toolKeys).toContain("getMissionLogs"); + expect(toolKeys).toContain("listMissionWorkerDigests"); + expect(toolKeys).toContain("steerMission"); + + // Worker tools + expect(toolKeys).toContain("listWorkers"); + expect(toolKeys).toContain("createWorker"); + expect(toolKeys).toContain("updateWorkerStatus"); + expect(toolKeys).toContain("wakeWorker"); + expect(toolKeys).toContain("getWorkerStatus"); + + // PR tools + expect(toolKeys).toContain("listPullRequests"); + expect(toolKeys).toContain("getPullRequestStatus"); + expect(toolKeys).toContain("commentOnPullRequest"); + expect(toolKeys).toContain("updatePullRequestTitle"); + expect(toolKeys).toContain("updatePullRequestBody"); + + // Linear issue routing / issue tools + expect(toolKeys).toContain("routeLinearIssueToCto"); + expect(toolKeys).toContain("routeLinearIssueToMission"); + expect(toolKeys).toContain("routeLinearIssueToWorker"); + expect(toolKeys).toContain("commentOnLinearIssue"); + expect(toolKeys).toContain("updateLinearIssueState"); + + // Process tools + expect(toolKeys).toContain("listManagedProcesses"); + expect(toolKeys).toContain("startManagedProcess"); + expect(toolKeys).toContain("stopManagedProcess"); + expect(toolKeys).toContain("getManagedProcessLog"); + + // File workspace tools + expect(toolKeys).toContain("listFileWorkspaces"); + expect(toolKeys).toContain("readWorkspaceFile"); + expect(toolKeys).toContain("searchWorkspaceText"); + }); + + // ── Chat tools ────────────────────────────────────────────────── + + describe("chat tools", () => { + it("returns bounded chat transcript reads through the chat service helper", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getChatTranscript as any).execute({ + sessionId: "chat-1", + limit: 5, + maxChars: 500, + }); + + expect(deps.getChatTranscript).toHaveBeenCalledWith({ sessionId: "chat-1", limit: 5, maxChars: 500 }); + expect(result).toMatchObject({ + success: true, + sessionId: "chat-1", + count: 1, + truncated: false, + }); }); - expect(deps.getChatTranscript).toHaveBeenCalledWith({ sessionId: "chat-1", limit: 5, maxChars: 500 }); - expect(result).toMatchObject({ - success: true, - sessionId: "chat-1", - count: 1, - truncated: false, + it("persists a requested chat title and returns navigation metadata when spawning chats", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.spawnChat as any).execute({ + title: "Backend follow-up", + initialPrompt: "Inspect the failing tests.", + openInUi: true, + }); + + expect(deps.createChat).toHaveBeenCalled(); + expect(deps.resolveExecutionLane).toHaveBeenCalledWith(expect.objectContaining({ + requestedLaneId: undefined, + purpose: "Backend follow-up", + })); + expect(deps.updateChatSession).toHaveBeenCalledWith({ + sessionId: "chat-1", + title: "Backend follow-up", + }); + expect(deps.sendChatMessage).toHaveBeenCalledWith({ + sessionId: "chat-1", + text: "Inspect the failing tests.", + }); + expect(result).toMatchObject({ + success: true, + sessionId: "chat-1", + navigation: { surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }, + navigationSuggestions: [{ surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }], + requestedTitle: "Backend follow-up", + }); }); - }); - it("persists a requested chat title and returns navigation metadata when spawning chats", async () => { - const deps = buildDeps(); - const tools = createCtoOperatorTools(deps); + it("spawns a chat without title or initial prompt", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.spawnChat as any).execute({ + openInUi: false, + }); - const result = await (tools.spawnChat as any).execute({ - title: "Backend follow-up", - initialPrompt: "Inspect the failing tests.", - openInUi: true, + expect(result.success).toBe(true); + expect(deps.updateChatSession).not.toHaveBeenCalled(); + expect(deps.sendChatMessage).not.toHaveBeenCalled(); + expect(result.requestedTitle).toBeNull(); }); - expect(deps.createChat).toHaveBeenCalled(); - expect(deps.updateChatSession).toHaveBeenCalledWith({ - sessionId: "chat-1", - title: "Backend follow-up", + it("lists chats with default options", async () => { + const chatList = [{ id: "chat-1", status: "idle" }]; + const deps = buildDeps({ + listChats: vi.fn().mockResolvedValue(chatList), + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listChats as any).execute({}); + + expect(result).toMatchObject({ success: true, count: 1, chats: chatList }); }); - expect(deps.sendChatMessage).toHaveBeenCalledWith({ - sessionId: "chat-1", - text: "Inspect the failing tests.", + + it("lists chats filtered by lane", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + await (tools.listChats as any).execute({ laneId: "lane-2", includeIdentity: true }); + + expect(deps.listChats).toHaveBeenCalledWith("lane-2", expect.objectContaining({ + includeAutomation: false, + })); }); - expect(result).toMatchObject({ - success: true, - sessionId: "chat-1", - navigation: { surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }, - navigationSuggestions: [{ surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }], - requestedTitle: "Backend follow-up", + + it("sends a message to a chat session", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.sendChatMessage as any).execute({ + sessionId: "chat-1", + text: "Hello from CTO", + }); + + expect(deps.sendChatMessage).toHaveBeenCalledWith({ + sessionId: "chat-1", + text: "Hello from CTO", + }); + expect(result).toMatchObject({ success: true, sessionId: "chat-1" }); }); - }); - it("updates missions, relaunches runs, and resolves interventions through stable services", async () => { - const mission = { id: "mission-1", title: "Mission" }; - const intervention = { id: "int-1", status: "resolved" }; - const deps = buildDeps({ - missionService: { - update: vi.fn().mockReturnValue(mission), - get: vi.fn().mockReturnValue(mission), - resolveIntervention: vi.fn().mockReturnValue(intervention), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-1" } }, mission }), - } as any, + it("interrupts a chat session", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.interruptChat as any).execute({ + sessionId: "chat-1", + }); + + expect(deps.interruptChat).toHaveBeenCalledWith({ sessionId: "chat-1" }); + expect(result).toMatchObject({ success: true, sessionId: "chat-1" }); }); - const tools = createCtoOperatorTools(deps); - const updated = await (tools.updateMission as any).execute({ - missionId: "mission-1", - title: "Updated title", - status: "in_progress", - outcomeSummary: "Working", + it("resumes a chat session and returns navigation", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.resumeChat as any).execute({ + sessionId: "chat-1", + }); + + expect(deps.resumeChat).toHaveBeenCalledWith({ sessionId: "chat-1" }); + expect(result).toMatchObject({ + success: true, + sessionId: "chat-1", + navigation: expect.objectContaining({ surface: "work" }), + }); }); - const launched = await (tools.launchMissionRun as any).execute({ - missionId: "mission-1", - runMode: "manual", + + it("ends a chat session", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.endChat as any).execute({ + sessionId: "chat-1", + }); + + expect(deps.disposeChat).toHaveBeenCalledWith({ sessionId: "chat-1" }); + expect(result).toMatchObject({ success: true, sessionId: "chat-1" }); }); - const resolved = await (tools.resolveMissionIntervention as any).execute({ - missionId: "mission-1", - interventionId: "int-1", - status: "resolved", - resolutionKind: "answer_provided", - note: "Use the existing implementation.", + + it("gets chat status and returns not found for missing sessions", async () => { + const deps = buildDeps({ + getChatStatus: vi.fn().mockResolvedValue(null), + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getChatStatus as any).execute({ + sessionId: "nonexistent", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Chat not found") }); }); - expect((deps.missionService as any).update).toHaveBeenCalledWith({ - missionId: "mission-1", - title: "Updated title", - status: "in_progress", - outcomeSummary: "Working", + it("gets chat status successfully", async () => { + const session = { id: "chat-1", status: "idle" }; + const deps = buildDeps({ + getChatStatus: vi.fn().mockResolvedValue(session), + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getChatStatus as any).execute({ + sessionId: "chat-1", + }); + + expect(result).toMatchObject({ success: true, session }); }); - expect((deps.aiOrchestratorService as any).startMissionRun).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - runMode: "manual", - metadata: { launchSource: "cto_operator_tools.launchMissionRun" }, - }), - ); - expect((deps.missionService as any).resolveIntervention).toHaveBeenCalledWith({ - missionId: "mission-1", - interventionId: "int-1", - status: "resolved", - resolutionKind: "answer_provided", - note: "Use the existing implementation.", - }); - expect(updated).toMatchObject({ success: true, mission }); - expect(launched).toMatchObject({ success: true, mission }); - expect(resolved).toMatchObject({ success: true, intervention }); }); - it("returns lane and mission navigation suggestions for operator-created ADE objects", async () => { - const lane = { id: "lane-2", name: "ops", branchRef: "refs/heads/ops" }; - const mission = { id: "mission-7", title: "Mission", laneId: "lane-2" }; - const deps = buildDeps({ - laneService: { - list: vi.fn().mockResolvedValue([lane]), - create: vi.fn().mockResolvedValue(lane), - } as any, - missionService: { - create: vi.fn().mockReturnValue(mission), - } as any, + // ── Lane tools ────────────────────────────────────────────────── + + describe("lane tools", () => { + it("lists lanes", async () => { + const lane = { id: "lane-1", name: "primary", status: "active" }; + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([lane]), + create: vi.fn(), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listLanes as any).execute({ includeArchived: false }); + + expect(result).toMatchObject({ success: true, count: 1 }); + expect(result.lanes[0]).toMatchObject({ id: "lane-1", name: "primary" }); }); - const tools = createCtoOperatorTools(deps); - const createdLane = await (tools.createLane as any).execute({ - name: "ops", + it("inspects a lane by ID and returns not found for missing lanes", async () => { + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.inspectLane as any).execute({ laneId: "nonexistent" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Lane not found") }); }); - const startedMission = await (tools.startMission as any).execute({ - prompt: "Investigate the failing deploy path.", - laneId: "lane-2", - launch: false, + + it("inspects a lane successfully", async () => { + const lane = { id: "lane-1", name: "primary", branchRef: "refs/heads/primary" }; + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([lane]), + create: vi.fn(), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.inspectLane as any).execute({ laneId: "lane-1" }); + + expect(result).toMatchObject({ success: true, lane }); }); - expect(createdLane).toMatchObject({ - success: true, - navigation: { surface: "lanes", laneId: "lane-2", href: "/lanes?laneId=lane-2" }, + it("returns lane and mission navigation suggestions for operator-created ADE objects", async () => { + const lane = { id: "lane-2", name: "ops", branchRef: "refs/heads/ops" }; + const mission = { id: "mission-7", title: "Mission", laneId: "lane-2" }; + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([lane]), + create: vi.fn().mockResolvedValue(lane), + } as any, + missionService: { + create: vi.fn().mockReturnValue(mission), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const createdLane = await (tools.createLane as any).execute({ + name: "ops", + }); + const startedMission = await (tools.startMission as any).execute({ + prompt: "Investigate the failing deploy path.", + laneId: "lane-2", + launch: false, + }); + + expect(createdLane).toMatchObject({ + success: true, + navigation: { surface: "lanes", laneId: "lane-2", href: "/lanes?laneId=lane-2" }, + }); + expect(startedMission).toMatchObject({ + success: true, + navigation: { surface: "missions", laneId: "lane-2", missionId: "mission-7", href: "/missions?missionId=mission-7&laneId=lane-2" }, + }); }); - expect(startedMission).toMatchObject({ - success: true, - navigation: { surface: "missions", laneId: "lane-2", missionId: "mission-7", href: "/missions?missionId=mission-7&laneId=lane-2" }, + + it("handles lane creation errors gracefully", async () => { + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockRejectedValue(new Error("Branch conflict")), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.createLane as any).execute({ name: "conflict-lane" }); + + expect(result).toMatchObject({ success: false, error: "Branch conflict" }); }); }); - it("surfaces mission runtime view, logs, worker digests, and steering through aiOrchestratorService", async () => { - const runView = { missionId: "mission-1", displayStatus: "running" }; - const logs = { entries: [{ id: "log-1" }], nextCursor: null, total: 1 }; - const digests = [{ id: "digest-1" }]; - const steerResult = { acknowledged: true, appliedAt: "2026-03-16T00:00:00.000Z" }; - const deps = buildDeps({ - aiOrchestratorService: { - getRunView: vi.fn().mockResolvedValue(runView), - getMissionLogs: vi.fn().mockResolvedValue(logs), - listWorkerDigests: vi.fn().mockReturnValue(digests), - steerMission: vi.fn().mockReturnValue(steerResult), - } as any, + // ── Mission tools ─────────────────────────────────────────────── + + describe("mission tools", () => { + it("updates missions, relaunches runs, and resolves interventions through stable services", async () => { + const mission = { id: "mission-1", title: "Mission" }; + const intervention = { id: "int-1", status: "resolved" }; + const deps = buildDeps({ + missionService: { + update: vi.fn().mockReturnValue(mission), + get: vi.fn().mockReturnValue(mission), + resolveIntervention: vi.fn().mockReturnValue(intervention), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-1" } }, mission }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const updated = await (tools.updateMission as any).execute({ + missionId: "mission-1", + title: "Updated title", + status: "in_progress", + outcomeSummary: "Working", + }); + const launched = await (tools.launchMissionRun as any).execute({ + missionId: "mission-1", + runMode: "manual", + }); + const resolved = await (tools.resolveMissionIntervention as any).execute({ + missionId: "mission-1", + interventionId: "int-1", + status: "resolved", + resolutionKind: "answer_provided", + note: "Use the existing implementation.", + }); + + expect((deps.missionService as any).update).toHaveBeenCalledWith({ + missionId: "mission-1", + title: "Updated title", + status: "in_progress", + outcomeSummary: "Working", + }); + expect((deps.aiOrchestratorService as any).startMissionRun).toHaveBeenCalledWith( + expect.objectContaining({ + missionId: "mission-1", + runMode: "manual", + metadata: { launchSource: "cto_operator_tools.launchMissionRun" }, + }), + ); + expect((deps.missionService as any).resolveIntervention).toHaveBeenCalledWith({ + missionId: "mission-1", + interventionId: "int-1", + status: "resolved", + resolutionKind: "answer_provided", + note: "Use the existing implementation.", + }); + expect(updated).toMatchObject({ success: true, mission }); + expect(launched).toMatchObject({ success: true, mission }); + expect(resolved).toMatchObject({ success: true, intervention }); }); - const tools = createCtoOperatorTools(deps); - const view = await (tools.getMissionRunView as any).execute({ missionId: "mission-1" }); - const missionLogs = await (tools.getMissionLogs as any).execute({ - missionId: "mission-1", - channels: ["runtime"], - limit: 25, + it("returns error when mission service is not available for listMissions", async () => { + const deps = buildDeps({ missionService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listMissions as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission service") }); }); - const workerDigests = await (tools.listMissionWorkerDigests as any).execute({ - missionId: "mission-1", - limit: 10, + + it("lists missions with filters", async () => { + const missions = [{ id: "m-1", status: "in_progress" }]; + const deps = buildDeps({ + missionService: { + list: vi.fn().mockReturnValue(missions), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listMissions as any).execute({ status: "in_progress" }); + + expect(result).toMatchObject({ success: true, count: 1, missions }); }); - const steered = await (tools.steerMission as any).execute({ - missionId: "mission-1", - directive: "Pause on migration cleanup and summarize the risk.", - priority: "override", + + it("returns error when mission not found for getMissionStatus", async () => { + const deps = buildDeps({ + missionService: { + get: vi.fn().mockReturnValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getMissionStatus as any).execute({ missionId: "nonexistent" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission not found") }); }); - expect(view).toMatchObject({ success: true, view: runView }); - expect(missionLogs).toMatchObject({ success: true, total: 1 }); - expect(workerDigests).toMatchObject({ success: true, count: 1, digests }); - expect(steered).toMatchObject({ success: true, result: steerResult }); - }); + it("returns error when mission not found for launchMissionRun", async () => { + const deps = buildDeps({ + missionService: { + get: vi.fn().mockReturnValue(null), + } as any, + aiOrchestratorService: {} as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.launchMissionRun as any).execute({ missionId: "nonexistent" }); - it.each(["approve", "reject", "retry", "complete"] as const)( - "resolves Linear run actions for %s", - async (action) => { + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission not found") }); + }); + + it("surfaces mission runtime view, logs, worker digests, and steering through aiOrchestratorService", async () => { + const runView = { missionId: "mission-1", displayStatus: "running" }; + const logs = { entries: [{ id: "log-1" }], nextCursor: null, total: 1 }; + const digests = [{ id: "digest-1" }]; + const steerResult = { acknowledged: true, appliedAt: "2026-03-16T00:00:00.000Z" }; const deps = buildDeps({ - flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, - linearDispatcherService: { - resolveRunAction: vi.fn().mockResolvedValue({ id: "run-1", status: "queued" }), + aiOrchestratorService: { + getRunView: vi.fn().mockResolvedValue(runView), + getMissionLogs: vi.fn().mockResolvedValue(logs), + listWorkerDigests: vi.fn().mockReturnValue(digests), + steerMission: vi.fn().mockReturnValue(steerResult), } as any, }); const tools = createCtoOperatorTools(deps); - const result = await (tools.resolveLinearRunAction as any).execute({ - runId: "run-1", - action, - note: "operator note", + const view = await (tools.getMissionRunView as any).execute({ missionId: "mission-1" }); + const missionLogs = await (tools.getMissionLogs as any).execute({ + missionId: "mission-1", + channels: ["runtime"], + limit: 25, + }); + const workerDigests = await (tools.listMissionWorkerDigests as any).execute({ + missionId: "mission-1", + limit: 10, + }); + const steered = await (tools.steerMission as any).execute({ + missionId: "mission-1", + directive: "Pause on migration cleanup and summarize the risk.", + priority: "override", }); - expect((deps.linearDispatcherService as any).resolveRunAction).toHaveBeenCalledWith( - "run-1", - action, - "operator note", - { workflows: [] }, - ); - expect(result).toMatchObject({ success: true, run: { id: "run-1" } }); - }, - ); - - it("cancels Linear runs and returns refreshed detail", async () => { - const detail = { run: { id: "run-1", status: "cancelled" } }; - const deps = buildDeps({ - flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, - linearDispatcherService: { - cancelRun: vi.fn().mockResolvedValue(undefined), - getRunDetail: vi.fn().mockResolvedValue(detail), - } as any, + expect(view).toMatchObject({ success: true, view: runView }); + expect(missionLogs).toMatchObject({ success: true, total: 1 }); + expect(workerDigests).toMatchObject({ success: true, count: 1, digests }); + expect(steered).toMatchObject({ success: true, result: steerResult }); }); - const tools = createCtoOperatorTools(deps); - const result = await (tools.cancelLinearRun as any).execute({ - runId: "run-1", - reason: "Need to reassign.", + it("returns error when aiOrchestratorService is null for getMissionRunView", async () => { + const deps = buildDeps({ aiOrchestratorService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getMissionRunView as any).execute({ missionId: "m-1" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission runtime service") }); }); - expect((deps.linearDispatcherService as any).cancelRun).toHaveBeenCalledWith("run-1", "Need to reassign.", { workflows: [] }); - expect(result).toMatchObject({ success: true, runId: "run-1", detail }); + it("returns error when run view is null for getMissionRunView", async () => { + const deps = buildDeps({ + aiOrchestratorService: { + getRunView: vi.fn().mockResolvedValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getMissionRunView as any).execute({ missionId: "m-1" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission run view not found") }); + }); }); - it("reroutes active Linear runs by cancelling first and then routing to a mission", async () => { - const mission = { id: "mission-9" }; - const deps = buildDeps({ - issueTracker: { - fetchIssueById: vi.fn().mockResolvedValue(issueFixture), - } as any, - missionService: { - create: vi.fn().mockReturnValue(mission), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-2" } }, mission }), - } as any, - flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, - linearDispatcherService: { - getRunDetail: vi.fn().mockResolvedValue({ - run: { id: "run-1", issueId: "issue-1", status: "awaiting_delegation" }, - issue: { id: "issue-1" }, - }), - cancelRun: vi.fn().mockResolvedValue(undefined), - } as any, + // ── Worker tools ──────────────────────────────────────────────── + + describe("worker tools", () => { + it("lists workers", async () => { + const workers = [{ id: "w-1", name: "Alice", role: "engineer" }]; + const deps = buildDeps({ + workerAgentService: { + listAgents: vi.fn().mockReturnValue(workers), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listWorkers as any).execute({ includeDeleted: false }); + + expect(result).toMatchObject({ success: true, count: 1, workers }); }); - const tools = createCtoOperatorTools(deps); - const result = await (tools.rerouteLinearRun as any).execute({ - runId: "run-1", - target: "mission", - reason: "Delegate through mission planning instead.", - runMode: "autopilot", + it("returns error when worker service is not available for listWorkers", async () => { + const deps = buildDeps({ workerAgentService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listWorkers as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Worker service") }); }); - expect((deps.linearDispatcherService as any).cancelRun).toHaveBeenCalledWith( - "run-1", - "Delegate through mission planning instead. (rerouted by CTO)", - { workflows: [] }, - ); - expect((deps.missionService as any).create).toHaveBeenCalled(); - expect(result).toMatchObject({ - success: true, - cancelledExistingRun: true, - rerouted: { success: true, mission }, + it("creates a worker", async () => { + const worker = { id: "w-2", name: "Bob", role: "qa" }; + const deps = buildDeps({ + workerAgentService: { + saveAgent: vi.fn().mockReturnValue(worker), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.createWorker as any).execute({ + name: "Bob", + role: "qa", + }); + + expect(result).toMatchObject({ success: true, worker }); }); - }); - it("reroutes terminal Linear runs without cancelling and can hand them back to the CTO session", async () => { - const deps = buildDeps({ - issueTracker: { - fetchIssueById: vi.fn().mockResolvedValue(issueFixture), - } as any, - flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, - linearDispatcherService: { - getRunDetail: vi.fn().mockResolvedValue({ - run: { id: "run-1", issueId: "issue-1", status: "failed" }, - issue: { id: "issue-1" }, - }), - cancelRun: vi.fn().mockResolvedValue(undefined), - } as any, - ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-recovery" }), + it("updates worker status", async () => { + const deps = buildDeps({ + workerAgentService: { + setAgentStatus: vi.fn(), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.updateWorkerStatus as any).execute({ + agentId: "w-1", + status: "paused", + }); + + expect(result).toMatchObject({ success: true, agentId: "w-1", status: "paused" }); }); - const tools = createCtoOperatorTools(deps); - const result = await (tools.rerouteLinearRun as any).execute({ - runId: "run-1", - target: "cto", - reason: "Escalate back to the operator.", - reuseExisting: false, + it("wakes a worker with a task prompt", async () => { + const wakeResult = { dispatched: true }; + const deps = buildDeps({ + workerHeartbeatService: { + triggerWakeup: vi.fn().mockResolvedValue(wakeResult), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.wakeWorker as any).execute({ + agentId: "w-1", + prompt: "Run the test suite.", + }); + + expect(result).toMatchObject({ success: true, dispatched: true }); }); - expect((deps.linearDispatcherService as any).cancelRun).not.toHaveBeenCalled(); - expect(deps.ensureCtoSession).toHaveBeenCalled(); - expect(deps.sendChatMessage).toHaveBeenCalledWith({ - sessionId: "cto-recovery", - text: expect.stringContaining("ADE-42: Fix workflow regression"), + it("returns error when workerHeartbeatService is null for wakeWorker", async () => { + const deps = buildDeps({ workerHeartbeatService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.wakeWorker as any).execute({ + agentId: "w-1", + prompt: "test", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Worker heartbeat service") }); }); - expect(result).toMatchObject({ - success: true, - cancelledExistingRun: false, - rerouted: { + + it("gets worker status with core memory and recent runs", async () => { + const worker = { id: "w-1", name: "Alice", status: "active" }; + const coreMemory = { notes: ["some note"] }; + const runs = [{ id: "run-1" }]; + const deps = buildDeps({ + workerAgentService: { + getAgent: vi.fn().mockReturnValue(worker), + getCoreMemory: vi.fn().mockReturnValue(coreMemory), + } as any, + workerHeartbeatService: { + listRuns: vi.fn().mockReturnValue(runs), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getWorkerStatus as any).execute({ agentId: "w-1" }); + + expect(result).toMatchObject({ success: true, - navigation: { surface: "cto", laneId: "lane-1", sessionId: "cto-recovery", href: "/cto" }, - }, + worker, + statusSummary: "Worker is active.", + coreMemory, + recentRuns: runs, + }); + }); + + it("returns not found for missing worker", async () => { + const deps = buildDeps({ + workerAgentService: { + getAgent: vi.fn().mockReturnValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getWorkerStatus as any).execute({ agentId: "nonexistent" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Worker not found") }); + }); + }); + + // ── PR tools ──────────────────────────────────────────────────── + + describe("PR tools", () => { + it("lists pull requests", async () => { + const prs = [{ id: "pr-1", title: "Fix bug" }]; + const deps = buildDeps({ + prService: { + refresh: vi.fn().mockResolvedValue(prs), + listAll: vi.fn().mockReturnValue(prs), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listPullRequests as any).execute({ refresh: true }); + + expect(result).toMatchObject({ success: true, count: 1, prs }); + }); + + it("returns error when prService is null", async () => { + const deps = buildDeps({ prService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listPullRequests as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("PR service") }); + }); + + it("comments on a pull request", async () => { + const comment = { id: "comment-1", body: "LGTM" }; + const deps = buildDeps({ + prService: { + addComment: vi.fn().mockResolvedValue(comment), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.commentOnPullRequest as any).execute({ + prId: "pr-1", + body: "LGTM", + }); + + expect(result).toMatchObject({ success: true, comment }); + }); + + it("updates pull request title", async () => { + const deps = buildDeps({ + prService: { + updateTitle: vi.fn().mockResolvedValue(undefined), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.updatePullRequestTitle as any).execute({ + prId: "pr-1", + title: "New title", + }); + + expect(result).toMatchObject({ success: true, prId: "pr-1", title: "New title" }); + }); + + it("updates pull request body", async () => { + const deps = buildDeps({ + prService: { + updateDescription: vi.fn().mockResolvedValue(undefined), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.updatePullRequestBody as any).execute({ + prId: "pr-1", + body: "New description", + }); + + expect(result).toMatchObject({ success: true, prId: "pr-1" }); + }); + }); + + // ── Linear workflow tools ─────────────────────────────────────── + + describe("Linear workflow tools", () => { + it.each(["approve", "reject", "retry", "complete"] as const)( + "resolves Linear run actions for %s", + async (action) => { + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + resolveRunAction: vi.fn().mockResolvedValue({ id: "run-1", status: "queued" }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.resolveLinearRunAction as any).execute({ + runId: "run-1", + action, + note: "operator note", + }); + + expect((deps.linearDispatcherService as any).resolveRunAction).toHaveBeenCalledWith( + "run-1", + action, + "operator note", + { workflows: [] }, + ); + expect(result).toMatchObject({ success: true, run: { id: "run-1" } }); + }, + ); + + it("cancels Linear runs and returns refreshed detail", async () => { + const detail = { run: { id: "run-1", status: "cancelled" } }; + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + cancelRun: vi.fn().mockResolvedValue(undefined), + getRunDetail: vi.fn().mockResolvedValue(detail), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.cancelLinearRun as any).execute({ + runId: "run-1", + reason: "Need to reassign.", + }); + + expect((deps.linearDispatcherService as any).cancelRun).toHaveBeenCalledWith("run-1", "Need to reassign.", { workflows: [] }); + expect(result).toMatchObject({ success: true, runId: "run-1", detail }); + }); + + it("returns error when Linear services are not available for listLinearWorkflows", async () => { + const deps = buildDeps({ linearDispatcherService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listLinearWorkflows as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Linear dispatcher") }); + }); + + it("lists active and queued Linear workflows", async () => { + const activeRuns = [{ id: "run-1" }]; + const queuedRuns = [{ id: "run-2" }]; + const deps = buildDeps({ + linearDispatcherService: { + listActiveRuns: vi.fn().mockReturnValue(activeRuns), + listQueue: vi.fn().mockReturnValue(queuedRuns), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listLinearWorkflows as any).execute({}); + + expect(result).toMatchObject({ success: true, activeRuns, queuedRuns }); + }); + + it("gets Linear run status", async () => { + const detail = { run: { id: "run-1", status: "in_progress" } }; + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue(detail), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getLinearRunStatus as any).execute({ runId: "run-1" }); + + expect(result).toMatchObject({ success: true, detail }); + }); + + it("returns error when Linear run is not found", async () => { + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getLinearRunStatus as any).execute({ runId: "nonexistent" }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Workflow run not found") }); + }); + + it("returns error when resolveRunAction returns null", async () => { + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + resolveRunAction: vi.fn().mockResolvedValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.resolveLinearRunAction as any).execute({ + runId: "nonexistent", + action: "approve", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Workflow run not found") }); + }); + + it("reroutes active Linear runs by cancelling first and then routing to a mission", async () => { + const mission = { id: "mission-9" }; + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + missionService: { + create: vi.fn().mockReturnValue(mission), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-2" } }, mission }), + } as any, + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue({ + run: { id: "run-1", issueId: "issue-1", status: "awaiting_delegation" }, + issue: { id: "issue-1" }, + }), + cancelRun: vi.fn().mockResolvedValue(undefined), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.rerouteLinearRun as any).execute({ + runId: "run-1", + target: "mission", + reason: "Delegate through mission planning instead.", + runMode: "autopilot", + }); + + expect((deps.linearDispatcherService as any).cancelRun).toHaveBeenCalledWith( + "run-1", + "Delegate through mission planning instead. (rerouted by CTO)", + { workflows: [] }, + ); + expect((deps.missionService as any).create).toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + cancelledExistingRun: true, + rerouted: { success: true, mission }, + }); + }); + + it("reroutes terminal Linear runs without cancelling and can hand them back to the CTO session", async () => { + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue({ + run: { id: "run-1", issueId: "issue-1", status: "failed" }, + issue: { id: "issue-1" }, + }), + cancelRun: vi.fn().mockResolvedValue(undefined), + } as any, + ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-recovery" }), + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.rerouteLinearRun as any).execute({ + runId: "run-1", + target: "cto", + reason: "Escalate back to the operator.", + reuseExisting: false, + }); + + expect((deps.linearDispatcherService as any).cancelRun).not.toHaveBeenCalled(); + expect(deps.ensureCtoSession).toHaveBeenCalled(); + expect(deps.sendChatMessage).toHaveBeenCalledWith({ + sessionId: "cto-recovery", + text: expect.stringContaining("ADE-42: Fix workflow regression"), + }); + expect(result).toMatchObject({ + success: true, + cancelledExistingRun: false, + rerouted: { + success: true, + navigation: { surface: "cto", laneId: "lane-1", sessionId: "cto-recovery", href: "/cto" }, + }, + }); + }); + + it("returns error when rerouteLinearRun finds no issue on the run", async () => { + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue({ + run: { id: "run-1", issueId: "", status: "failed" }, + issue: null, + }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.rerouteLinearRun as any).execute({ + runId: "run-1", + target: "cto", + reason: "test", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("no associated issue") }); + }); + + it("returns error when rerouteLinearRun cannot find the run", async () => { + const deps = buildDeps({ + flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, + linearDispatcherService: { + getRunDetail: vi.fn().mockResolvedValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.rerouteLinearRun as any).execute({ + runId: "nonexistent", + target: "cto", + reason: "test", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Workflow run not found") }); + }); + }); + + // ── Linear issue routing tools ────────────────────────────────── + + describe("Linear issue routing tools", () => { + it("routes a Linear issue to the CTO session", async () => { + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }), + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToCto as any).execute({ + issueId: "issue-1", + }); + + expect(result).toMatchObject({ success: true }); + expect(deps.ensureCtoSession).toHaveBeenCalled(); + }); + + it("routes a Linear issue to a mission", async () => { + const mission = { id: "m-1", laneId: "lane-1" }; + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + missionService: { + create: vi.fn().mockReturnValue(mission), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn().mockResolvedValue({ mission }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToMission as any).execute({ + issueId: "issue-1", + }); + + expect(result).toMatchObject({ success: true, mission }); + }); + + it("returns error when issue tracker is not available for routing", async () => { + const deps = buildDeps({ issueTracker: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToCto as any).execute({ + issueId: "issue-1", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("issue tracker") }); + }); + + it("returns error when issue is not found for routing", async () => { + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(null), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToCto as any).execute({ + issueId: "nonexistent", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Issue not found") }); + }); + + it("routes a Linear issue to a worker", async () => { + const wakeResult = { dispatched: true }; + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn().mockResolvedValue(wakeResult), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToWorker as any).execute({ + issueId: "issue-1", + agentId: "w-1", + }); + + expect(result).toMatchObject({ success: true, dispatched: true }); + }); + + it("returns error when agentId is empty for routing to worker", async () => { + const deps = buildDeps({ + issueTracker: { + fetchIssueById: vi.fn().mockResolvedValue(issueFixture), + } as any, + workerHeartbeatService: {} as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.routeLinearIssueToWorker as any).execute({ + issueId: "issue-1", + agentId: " ", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("agentId is required") }); + }); + }); + + // ── Linear issue tools ────────────────────────────────────────── + + describe("Linear issue tools", () => { + it("comments on a Linear issue", async () => { + const comment = { id: "comment-1" }; + const deps = buildDeps({ + issueTracker: { + createComment: vi.fn().mockResolvedValue(comment), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.commentOnLinearIssue as any).execute({ + issueId: "issue-1", + body: "Working on it.", + }); + + expect(result).toMatchObject({ success: true, comment }); + }); + + it("returns error when issue tracker is not available for commenting", async () => { + const deps = buildDeps({ issueTracker: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.commentOnLinearIssue as any).execute({ + issueId: "issue-1", + body: "test", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("issue tracker") }); + }); + + it("updates a Linear issue state by stateId", async () => { + const deps = buildDeps({ + issueTracker: { + updateIssueState: vi.fn().mockResolvedValue(undefined), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.updateLinearIssueState as any).execute({ + issueId: "issue-1", + stateId: "state-done", + }); + + expect(result).toMatchObject({ success: true, issueId: "issue-1", stateId: "state-done" }); + }); + + it("returns error when neither stateId nor stateName is provided", async () => { + const deps = buildDeps({ + issueTracker: {} as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.updateLinearIssueState as any).execute({ + issueId: "issue-1", + }); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Provide either stateId or stateName") }); + }); + }); + + // ── Process tools ─────────────────────────────────────────────── + + describe("process tools", () => { + it("lists managed processes", async () => { + const defs = [{ id: "proc-1" }]; + const runtime = [{ id: "proc-1", status: "running" }]; + const deps = buildDeps({ + processService: { + listDefinitions: vi.fn().mockReturnValue(defs), + listRuntime: vi.fn().mockReturnValue(runtime), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listManagedProcesses as any).execute({}); + + expect(result).toMatchObject({ success: true, definitions: defs, runtime }); + }); + + it("returns error when processService is null", async () => { + const deps = buildDeps({ processService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listManagedProcesses as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("Process service") }); + }); + + it("starts a managed process", async () => { + const runtime = { id: "proc-1", status: "running" }; + const deps = buildDeps({ + processService: { + start: vi.fn().mockResolvedValue(runtime), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.startManagedProcess as any).execute({ + processId: "proc-1", + }); + + expect(result).toMatchObject({ success: true, runtime }); + }); + + it("stops a managed process", async () => { + const runtime = { id: "proc-1", status: "stopped" }; + const deps = buildDeps({ + processService: { + stop: vi.fn().mockResolvedValue(runtime), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.stopManagedProcess as any).execute({ + processId: "proc-1", + }); + + expect(result).toMatchObject({ success: true, runtime }); + }); + + it("reads bounded process log tail", async () => { + const deps = buildDeps({ + processService: { + getLogTail: vi.fn().mockReturnValue("line 1\nline 2\n"), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getManagedProcessLog as any).execute({ + processId: "proc-1", + }); + + expect(result).toMatchObject({ success: true, content: "line 1\nline 2\n" }); + }); + }); + + // ── File workspace tools ──────────────────────────────────────── + + describe("file workspace tools", () => { + it("lists file workspaces", async () => { + const workspaces = [{ id: "ws-1", laneId: "lane-1" }]; + const deps = buildDeps({ + fileService: { + listWorkspaces: vi.fn().mockReturnValue(workspaces), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listFileWorkspaces as any).execute({}); + + expect(result).toMatchObject({ success: true, count: 1, workspaces }); + }); + + it("returns error when fileService is null", async () => { + const deps = buildDeps({ fileService: null }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.listFileWorkspaces as any).execute({}); + + expect(result).toMatchObject({ success: false, error: expect.stringContaining("File service") }); }); }); }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 49ff07a0e..4e0cf1134 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -24,12 +24,19 @@ import type { createMissionService } from "../../missions/missionService"; import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService"; import type { createPrService } from "../../prs/prService"; import type { createProcessService } from "../../processes/processService"; +import { getErrorMessage } from "../../shared/utils"; export interface CtoOperatorToolDeps { currentSessionId: string; defaultLaneId: string; defaultModelId?: string | null; defaultReasoningEffort?: string | null; + resolveExecutionLane: (args: { + requestedLaneId?: string | null; + purpose: string; + freshLaneName?: string | null; + freshLaneDescription?: string | null; + }) => Promise; laneService: ReturnType; missionService?: ReturnType | null; aiOrchestratorService?: ReturnType | null; @@ -315,7 +322,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { if (!deps.missionService) return { success: false, error: "Mission service is not available." }; try { + const executionLaneId = await deps.resolveExecutionLane({ + requestedLaneId: laneId?.trim() || undefined, + purpose: title?.trim() || "mission", + freshLaneName: title?.trim() || "mission", + freshLaneDescription: "Dedicated mission lane launched from the CTO coordinator chat.", + }); const mission = deps.missionService.create({ prompt, ...(title?.trim() ? { title: title.trim() } : {}), - ...(laneId?.trim() ? { laneId: laneId.trim() } : {}), + laneId: executionLaneId, ...(priority ? { priority } : {}), autostart: false, launchMode: runMode, @@ -651,12 +670,12 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + for (const dir of tmpDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + tmpDirs.length = 0; +}); + function sandboxWith(overrides: Partial): WorkerSandboxConfig { return { ...DEFAULT_WORKER_SANDBOX_CONFIG, @@ -13,6 +27,10 @@ function sandboxWith(overrides: Partial): WorkerSandboxConf }; } +// ============================================================================ +// checkWorkerSandbox +// ============================================================================ + describe("checkWorkerSandbox", () => { it("blocks protected file writes even when command matches a safe allowlist pattern", () => { const result = checkWorkerSandbox("echo hello > .env", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); @@ -33,11 +51,158 @@ describe("checkWorkerSandbox", () => { expect(result.allowed).toBe(false); expect(result.reason).toContain("Path outside sandbox"); }); + + it("blocks commands matching explicit blocked patterns", () => { + const config = sandboxWith({ + blockedCommands: ["\\brm\\s+-rf\\b"], + }); + const result = checkWorkerSandbox("rm -rf /", config, "/tmp/project"); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Blocked command pattern"); + }); + + it("allows safe-listed read-only commands inside the project root", () => { + const result = checkWorkerSandbox("ls -la ./src", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); + expect(result.allowed).toBe(true); + }); + + it("allows paths within allowed extra directories", () => { + const cwd = "/tmp/project"; + const config = sandboxWith({ + allowedPaths: ["./", "/tmp/extra"], + }); + const result = checkWorkerSandbox("cat /tmp/extra/data.json", config, cwd); + expect(result.allowed).toBe(true); + }); + + it("allows read-only access to /usr/bin and /usr/local/bin paths", () => { + const result = checkWorkerSandbox("cat /usr/bin/env", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); + expect(result.allowed).toBe(true); + }); + + it("rejects mutating writes into /usr/local/bin even under the default sandbox", () => { + const result = checkWorkerSandbox("cp ./payload /usr/local/bin/tool", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("blocks commands that are not in the safe list when blockByDefault is enabled", () => { + const config = sandboxWith({ + blockByDefault: true, + safeCommands: ["^echo\\b"], + }); + const result = checkWorkerSandbox("curl http://example.com", config, "/tmp/project"); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("blockByDefault"); + }); + + it("allows commands matching safeCommands when blockByDefault is enabled", () => { + const config = sandboxWith({ + blockByDefault: true, + safeCommands: ["^echo\\b"], + }); + const result = checkWorkerSandbox("echo hello", config, "/tmp/project"); + expect(result.allowed).toBe(true); + }); + + it("detects home directory expansion in paths", () => { + const cwd = "/tmp/project"; + const config = sandboxWith({ + allowedPaths: ["./"], + }); + const result = checkWorkerSandbox("cat ~/some-file.txt", config, cwd); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("detects redirect target paths for write-like commands", () => { + const cwd = "/tmp/project"; + const config = sandboxWith({ + protectedFiles: ["\\.env"], + }); + const result = checkWorkerSandbox("echo secret >> .env", config, cwd); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protected file pattern"); + }); + + it("allows commands with no path references at all", () => { + const result = checkWorkerSandbox("echo hello world", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); + expect(result.allowed).toBe(true); + }); + + it("handles URL-like tokens without treating them as paths", () => { + const result = checkWorkerSandbox("curl https://example.com/api", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); + // Should not try to resolve URLs as filesystem paths + if (result.reason) { + expect(result.reason).not.toContain("Path outside sandbox"); + } + }); + + it("blocks write to protected file via cp command", () => { + const config = sandboxWith({ + protectedFiles: ["\\.env"], + }); + const result = checkWorkerSandbox("cp my-secrets .env", config, "/tmp/project"); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protected file pattern"); + }); }); +// ============================================================================ +// createUniversalToolSet +// ============================================================================ + describe("createUniversalToolSet", () => { + // ── Tool set structure ────────────────────────────────────────── + + it("returns all expected tool keys in the default configuration", () => { + const cwd = makeTmpDir("ade-tools-keys-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + expect(tools.readFile).toBeDefined(); + expect(tools.grep).toBeDefined(); + expect(tools.glob).toBeDefined(); + expect(tools.listDir).toBeDefined(); + expect(tools.gitStatus).toBeDefined(); + expect(tools.gitDiff).toBeDefined(); + expect(tools.gitLog).toBeDefined(); + expect(tools.webFetch).toBeDefined(); + expect(tools.webSearch).toBeDefined(); + expect(tools.editFile).toBeDefined(); + expect(tools.writeFile).toBeDefined(); + expect(tools.bash).toBeDefined(); + expect(tools.askUser).toBeDefined(); + }); + + it("does not include memory tools when memoryService is not provided", () => { + const cwd = makeTmpDir("ade-tools-nomem-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + expect(tools.memorySearch).toBeUndefined(); + expect(tools.memoryAdd).toBeUndefined(); + }); + + it("includes memoryUpdateCore tool when onMemoryUpdateCore is provided", () => { + const cwd = makeTmpDir("ade-tools-memcore-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + onMemoryUpdateCore: () => ({ version: 1, updatedAt: new Date().toISOString() }), + }); + + expect(tools.memoryUpdateCore).toBeDefined(); + }); + + it("does not include memoryUpdateCore tool when onMemoryUpdateCore is not provided", () => { + const cwd = makeTmpDir("ade-tools-nomemcore-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + expect(tools.memoryUpdateCore).toBeUndefined(); + }); + + // ── Sandbox enforcement ───────────────────────────────────────── + it("applies DEFAULT_WORKER_SANDBOX_CONFIG when sandboxConfig is omitted", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-default-sandbox-")); + const cwd = makeTmpDir("ade-tools-default-sandbox-"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); const bashTool = tools.bash as any; @@ -50,10 +215,13 @@ describe("createUniversalToolSet", () => { expect(result.stderr).toContain("SANDBOX BLOCKED"); }); + // ── writeFile tool ────────────────────────────────────────────── + it("blocks writeFile writes outside project root when no explicit allowlist is provided", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-write-root-")); + const cwd = makeTmpDir("ade-tools-write-root-"); const outsideDir = `${cwd}-outside`; fs.mkdirSync(outsideDir, { recursive: true }); + tmpDirs.push(outsideDir); const outsidePath = path.join(outsideDir, "blocked.txt"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); const writeTool = tools.writeFile as any; @@ -69,7 +237,7 @@ describe("createUniversalToolSet", () => { }); it("allows writeFile within project root", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-write-allowed-")); + const cwd = makeTmpDir("ade-tools-write-allowed-"); const targetPath = path.join(cwd, "notes", "output.txt"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); const writeTool = tools.writeFile as any; @@ -84,8 +252,8 @@ describe("createUniversalToolSet", () => { }); it("allows writeFile outside project root when sandbox allowlist explicitly permits it", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-write-allowlist-root-")); - const allowlistedDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-write-allowlist-extra-")); + const cwd = makeTmpDir("ade-tools-write-allowlist-root-"); + const allowlistedDir = makeTmpDir("ade-tools-write-allowlist-extra-"); const targetPath = path.join(allowlistedDir, "allowed.txt"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto", @@ -102,8 +270,199 @@ describe("createUniversalToolSet", () => { expect(fs.readFileSync(targetPath, "utf-8")).toBe("allowlisted"); }); + it("creates parent directories automatically for writeFile", async () => { + const cwd = makeTmpDir("ade-tools-write-mkdir-"); + const deepPath = path.join(cwd, "a", "b", "c", "file.txt"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.writeFile as any).execute({ + file_path: deepPath, + content: "deep write", + }); + + expect(result.success).toBe(true); + expect(fs.readFileSync(deepPath, "utf-8")).toBe("deep write"); + }); + + it("blocks writeFile to protected files when the raw path matches a protected pattern", async () => { + const cwd = makeTmpDir("ade-tools-write-protected-raw-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + sandboxConfig: sandboxWith({ protectedFiles: ["(^|/)\\.env$"] }), + }); + + const result = await (tools.writeFile as any).execute({ + file_path: ".env", + content: "SECRET=value\n", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("protected file pattern"); + expect(fs.existsSync(path.join(cwd, ".env"))).toBe(false); + }); + + it("blocks writeFile through symlinked directories that escape the allowed roots", async () => { + const cwd = makeTmpDir("ade-tools-write-symlink-root-"); + const outsideDir = makeTmpDir("ade-tools-write-symlink-outside-"); + const linkedDir = path.join(cwd, "linked-outside"); + fs.symlinkSync(outsideDir, linkedDir, "dir"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.writeFile as any).execute({ + file_path: path.join(linkedDir, "escape.txt"), + content: "blocked", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("outside allowed roots"); + expect(fs.existsSync(path.join(outsideDir, "escape.txt"))).toBe(false); + }); + + // ── editFile tool ─────────────────────────────────────────────── + + it("performs a single-occurrence edit successfully", async () => { + const cwd = makeTmpDir("ade-tools-edit-"); + const filePath = path.join(cwd, "target.txt"); + fs.writeFileSync(filePath, "Hello world\nfoo bar\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "foo bar", + new_string: "baz qux", + }); + + expect(result.success).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello world\nbaz qux\n"); + }); + + it("fails when old_string is not found", async () => { + const cwd = makeTmpDir("ade-tools-edit-notfound-"); + const filePath = path.join(cwd, "target.txt"); + fs.writeFileSync(filePath, "Hello world\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "does not exist", + new_string: "replacement", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("not found"); + }); + + it("fails when old_string matches multiple times without replace_all", async () => { + const cwd = makeTmpDir("ade-tools-edit-multi-"); + const filePath = path.join(cwd, "target.txt"); + fs.writeFileSync(filePath, "foo bar\nfoo bar\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "foo bar", + new_string: "baz", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("multiple times"); + }); + + it("replaces all occurrences when replace_all is true", async () => { + const cwd = makeTmpDir("ade-tools-edit-replaceall-"); + const filePath = path.join(cwd, "target.txt"); + fs.writeFileSync(filePath, "foo bar\nfoo bar\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "foo bar", + new_string: "baz", + replace_all: true, + }); + + expect(result.success).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("baz\nbaz\n"); + }); + + it("returns an error when the file does not exist for editFile", async () => { + const cwd = makeTmpDir("ade-tools-edit-missing-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: path.join(cwd, "nonexistent.txt"), + old_string: "foo", + new_string: "bar", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("File not found"); + }); + + it("blocks editFile outside the configured sandbox roots", async () => { + const cwd = makeTmpDir("ade-tools-edit-sandbox-"); + const outsideDir = makeTmpDir("ade-tools-edit-sandbox-outside-"); + const filePath = path.join(outsideDir, "target.txt"); + fs.writeFileSync(filePath, "Hello world\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + sandboxConfig: sandboxWith({ allowedPaths: ["./"] }), + }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "Hello", + new_string: "Goodbye", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("outside allowed roots"); + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello world\n"); + }); + + it("blocks editFile when the resolved path matches a protected pattern", async () => { + const cwd = makeTmpDir("ade-tools-edit-protected-resolved-"); + const filePath = path.join(cwd, ".env"); + fs.writeFileSync(filePath, "TOKEN=one\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + sandboxConfig: sandboxWith({ protectedFiles: ["(^|/)\\.env$"] }), + }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "one", + new_string: "two", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("protected file pattern"); + expect(fs.readFileSync(filePath, "utf-8")).toBe("TOKEN=one\n"); + }); + + it("blocks editFile through symlinked directories that escape the allowed roots", async () => { + const cwd = makeTmpDir("ade-tools-edit-symlink-root-"); + const outsideDir = makeTmpDir("ade-tools-edit-symlink-outside-"); + const linkedDir = path.join(cwd, "linked-outside"); + const outsideFile = path.join(outsideDir, "escape.txt"); + fs.writeFileSync(outsideFile, "outside\n", "utf-8"); + fs.symlinkSync(outsideDir, linkedDir, "dir"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.editFile as any).execute({ + file_path: path.join(linkedDir, "escape.txt"), + old_string: "outside", + new_string: "inside", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("outside allowed roots"); + expect(fs.readFileSync(outsideFile, "utf-8")).toBe("outside\n"); + }); + // ── Memory guard ──────────────────────────────────────────────── + it("blocks mutating tools on required turns until memory orientation is satisfied", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-memory-guard-")); + const cwd = makeTmpDir("ade-tools-memory-guard-"); const targetPath = path.join(cwd, "blocked.txt"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto", @@ -124,8 +483,52 @@ describe("createUniversalToolSet", () => { expect(fs.existsSync(targetPath)).toBe(false); }); + it("blocks editFile on required turns until memory orientation is satisfied", async () => { + const cwd = makeTmpDir("ade-tools-memory-guard-edit-"); + const filePath = path.join(cwd, "edit-target.txt"); + fs.writeFileSync(filePath, "original\n", "utf-8"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + turnMemoryPolicyState: { + classification: "required", + orientationSatisfied: false, + explicitSearchPerformed: false, + }, + }); + + const result = await (tools.editFile as any).execute({ + file_path: filePath, + old_string: "original", + new_string: "modified", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("Search memory before mutating files"); + expect(fs.readFileSync(filePath, "utf-8")).toBe("original\n"); + }); + + it("blocks mutating bash commands on required turns", async () => { + const cwd = makeTmpDir("ade-tools-memory-guard-bash-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + turnMemoryPolicyState: { + classification: "required", + orientationSatisfied: false, + explicitSearchPerformed: false, + }, + }); + + const result = await (tools.bash as any).execute({ + command: "rm -rf ./some-dir", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(126); + expect(result.stderr).toContain("EXECUTION DENIED"); + }); + it("does not block read-only bash commands on required turns", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tools-memory-readonly-")); + const cwd = makeTmpDir("ade-tools-memory-readonly-"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto", turnMemoryPolicyState: { @@ -142,4 +545,327 @@ describe("createUniversalToolSet", () => { expect(result.stderr).not.toContain("EXECUTION DENIED"); }); + + it("allows mutating tools once memory orientation is satisfied", async () => { + const cwd = makeTmpDir("ade-tools-memory-satisfied-"); + const targetPath = path.join(cwd, "allowed.txt"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + turnMemoryPolicyState: { + classification: "required", + orientationSatisfied: true, + explicitSearchPerformed: true, + }, + }); + + const result = await (tools.writeFile as any).execute({ + file_path: targetPath, + content: "allowed write", + }); + + expect(result.success).toBe(true); + }); + + it("does not block when classification is casual", async () => { + const cwd = makeTmpDir("ade-tools-memory-casual-"); + const targetPath = path.join(cwd, "casual.txt"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + turnMemoryPolicyState: { + classification: "none", + orientationSatisfied: false, + explicitSearchPerformed: false, + }, + }); + + const result = await (tools.writeFile as any).execute({ + file_path: targetPath, + content: "casual write", + }); + + expect(result.success).toBe(true); + }); + + // ── Permission modes ──────────────────────────────────────────── + + it("allows bash execution in plan mode when no approval handler is configured", async () => { + const cwd = makeTmpDir("ade-tools-plan-deny-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "plan" }); + + const result = await (tools.bash as any).execute({ + command: "echo hello", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello"); + }); + + it("allows write in plan mode when no approval handler is configured", async () => { + const cwd = makeTmpDir("ade-tools-plan-write-deny-"); + const targetPath = path.join(cwd, "allowed.txt"); + const tools = createUniversalToolSet(cwd, { permissionMode: "plan" }); + + const result = await (tools.writeFile as any).execute({ + file_path: targetPath, + content: "allowed", + }); + + expect(result.success).toBe(true); + }); + + it("allows bash execution in edit mode when no approval handler is configured", async () => { + const cwd = makeTmpDir("ade-tools-edit-deny-bash-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "edit" }); + + const result = await (tools.bash as any).execute({ + command: "echo hello", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello"); + }); + + it("allows writeFile in edit mode without approval handler", async () => { + const cwd = makeTmpDir("ade-tools-edit-allow-write-"); + const targetPath = path.join(cwd, "allowed.txt"); + const tools = createUniversalToolSet(cwd, { permissionMode: "edit" }); + + const result = await (tools.writeFile as any).execute({ + file_path: targetPath, + content: "edit-mode write", + }); + + expect(result.success).toBe(true); + }); + + it("invokes approval handler and allows if approved", async () => { + const cwd = makeTmpDir("ade-tools-approval-allow-"); + const onApprovalRequest = vi.fn().mockResolvedValue({ approved: true }); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest, + }); + + const result = await (tools.bash as any).execute({ + command: "echo approved", + timeout: 5_000, + }); + + expect(onApprovalRequest).toHaveBeenCalledWith( + expect.objectContaining({ + category: "bash", + description: expect.stringContaining("echo approved"), + }), + ); + expect(result.exitCode).not.toBe(126); + }); + + it("invokes approval handler and blocks if rejected", async () => { + const cwd = makeTmpDir("ade-tools-approval-deny-"); + const onApprovalRequest = vi.fn().mockResolvedValue({ approved: false, reason: "user rejected" }); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest, + }); + + const result = await (tools.bash as any).execute({ + command: "echo rejected", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(126); + expect(result.stderr).toContain("user rejected"); + }); + + // ── askUser tool ──────────────────────────────────────────────── + + it("returns error when askUser callback is not configured", async () => { + const cwd = makeTmpDir("ade-tools-askuser-nocb-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.askUser as any).execute({ question: "What?" }); + + expect(result.error).toContain("not configured"); + }); + + it("returns user answer from askUser callback", async () => { + const cwd = makeTmpDir("ade-tools-askuser-cb-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + onAskUser: async () => "user answer", + }); + + const result = await (tools.askUser as any).execute({ question: "What?" }); + + expect(result.answer).toBe("user answer"); + }); + + // ── exitPlanMode tool ─────────────────────────────────────────── + + it("does not expose exitPlanMode in non-plan permission modes", async () => { + const cwd = makeTmpDir("ade-tools-exitplan-nonplan-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + expect(tools.exitPlanMode).toBeUndefined(); + }); + + it("returns failure when no approval handler is configured for exitPlanMode", async () => { + const cwd = makeTmpDir("ade-tools-exitplan-nocb-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "plan" }); + + const result = await (tools.exitPlanMode as any).execute({}); + + expect(result.approved).toBe(false); + expect(result.message).toContain("No approval handler"); + }); + + it("returns approved when user approves plan exit", async () => { + const cwd = makeTmpDir("ade-tools-exitplan-approve-"); + const onApprovalRequest = vi.fn().mockResolvedValue({ approved: true }); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest, + }); + + const result = await (tools.exitPlanMode as any).execute({ + planDescription: "My plan summary", + }); + + expect(result.approved).toBe(true); + expect(result.message).toContain("Proceed with implementation"); + }); + + it("returns feedback when user rejects plan exit", async () => { + const cwd = makeTmpDir("ade-tools-exitplan-reject-"); + const onApprovalRequest = vi.fn().mockResolvedValue({ + approved: false, + reason: "Please add more tests first.", + }); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest, + }); + + const result = await (tools.exitPlanMode as any).execute({}); + + expect(result.approved).toBe(false); + expect(result.message).toContain("Please add more tests first"); + }); + + it("fails closed when an approval callback throws for writeFile", async () => { + const cwd = makeTmpDir("ade-tools-write-approval-error-"); + const targetPath = path.join(cwd, "blocked.txt"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest: vi.fn().mockRejectedValue(new Error("approval bridge unavailable")), + }); + + const result = await (tools.writeFile as any).execute({ + file_path: targetPath, + content: "blocked", + }); + + expect(result.success).toBe(false); + expect(result.message).toContain("approval bridge unavailable"); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("fails closed when the plan approval bridge throws", async () => { + const cwd = makeTmpDir("ade-tools-exitplan-error-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "plan", + onApprovalRequest: vi.fn().mockRejectedValue(new Error("bridge disconnected")), + }); + + const result = await (tools.exitPlanMode as any).execute({ + planDescription: "Ship the fix", + }); + + expect(result.approved).toBe(false); + expect(result.message).toContain("bridge disconnected"); + }); + + // ── memoryUpdateCore tool ─────────────────────────────────────── + + it("invokes onMemoryUpdateCore with patch and returns result", async () => { + const cwd = makeTmpDir("ade-tools-memcore-exec-"); + const onMemoryUpdateCore = vi.fn().mockReturnValue({ + version: 2, + updatedAt: "2026-03-26T00:00:00.000Z", + }); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + onMemoryUpdateCore, + }); + + const result = await (tools.memoryUpdateCore as any).execute({ + projectSummary: "An ADE desktop application.", + activeFocus: ["Release 9 stabilization"], + }); + + expect(onMemoryUpdateCore).toHaveBeenCalledWith( + expect.objectContaining({ + projectSummary: "An ADE desktop application.", + activeFocus: ["Release 9 stabilization"], + }), + ); + expect(result.updated).toBe(true); + expect(result.version).toBe(2); + }); + + it("returns error from memoryUpdateCore when no fields are provided", async () => { + const cwd = makeTmpDir("ade-tools-memcore-empty-"); + const onMemoryUpdateCore = vi.fn(); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + onMemoryUpdateCore, + }); + + const result = await (tools.memoryUpdateCore as any).execute({}); + + expect(result.updated).toBe(false); + expect(result.error).toContain("At least one core-memory field"); + expect(onMemoryUpdateCore).not.toHaveBeenCalled(); + }); + + // ── bash tool ─────────────────────────────────────────────────── + + it("executes a basic bash command and returns output", async () => { + const cwd = makeTmpDir("ade-tools-bash-basic-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.bash as any).execute({ + command: "echo hello from bash", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello from bash"); + }); + + it("returns nonzero exit code for failing commands", async () => { + const cwd = makeTmpDir("ade-tools-bash-fail-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + const result = await (tools.bash as any).execute({ + command: "exit 42", + timeout: 5_000, + }); + + expect(result.exitCode).toBe(42); + }); + + it("clamps timeout to max 600000ms", async () => { + const cwd = makeTmpDir("ade-tools-bash-timeout-clamp-"); + const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); + + // Just verify it doesn't throw; internally the timeout is clamped + const result = await (tools.bash as any).execute({ + command: "echo clamped", + timeout: 9_999_999, + }); + + expect(result.exitCode).toBe(0); + }); }); diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index deeaeae6b..1b9c2f444 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -14,7 +14,7 @@ import { createMemoryTools, type MemoryWriteEvent, type TurnMemoryPolicyState } import type { createUnifiedMemoryService } from "../../memory/unifiedMemoryService"; import type { AgentChatApprovalDecision, WorkerSandboxConfig, CtoCoreMemory } from "../../../../shared/types"; import { DEFAULT_WORKER_SANDBOX_CONFIG } from "../../orchestrator/orchestratorConstants"; -import { isWithinDir } from "../../shared/utils"; +import { getErrorMessage, isEnoentError, isWithinDir } from "../../shared/utils"; const execFileAsync = promisify(execFile); @@ -47,7 +47,7 @@ export interface UniversalToolSetOptions { type ToolCategory = "read" | "write" | "bash"; type ToolApprovalRequest = { - category: Exclude; + category: Exclude | "exitPlanMode"; description: string; detail?: unknown; }; @@ -69,15 +69,14 @@ function requiresApproval(mode: PermissionMode, category: ToolCategory): boolean } } -function makeApproval( +function approvalProp( mode: PermissionMode, category: ToolCategory, useManualApproval: boolean, -) { +): { needsApproval: boolean } | Record { const needs = requiresApproval(mode, category); - if (!needs || useManualApproval) return undefined; - // Return a static async function so the AI SDK gates execution - return async () => true; + if (!needs || useManualApproval) return {}; + return { needsApproval: true }; } async function maybeRequestApproval(args: { @@ -92,18 +91,21 @@ async function maybeRequestApproval(args: { } if (!args.onApprovalRequest) { + return { approved: true }; + } + + try { + return await args.onApprovalRequest({ + category: args.category, + description: args.description, + detail: args.detail, + }); + } catch (err) { return { approved: false, - decision: "decline", - reason: "Approval is required, but no approval handler is configured.", + reason: getErrorMessage(err) || "Approval request failed.", }; } - - return args.onApprovalRequest({ - category: args.category, - description: args.description, - detail: args.detail, - }); } // ── Worker sandbox enforcement ────────────────────────────────────── @@ -134,6 +136,13 @@ const MUTATING_BASH_RE = /\b(?:rm|mv|cp|mkdir|touch|chmod|chown|patch|install|un const MEMORY_GUARD_REASON = "Search memory before mutating files or running mutating commands for this turn."; +type PathAccessMode = "read" | "write" | "unknown"; +type PathReference = { + raw: string; + resolved: string; + access: PathAccessMode; +}; + function requiresTurnMemoryGuard(state?: TurnMemoryPolicyState): boolean { return !!state && state.classification === "required" && !state.orientationSatisfied && !state.explicitSearchPerformed; } @@ -153,6 +162,81 @@ function resolveAllowedWriteRoots(cwd: string, sandboxConfig?: WorkerSandboxConf return [...roots]; } +function canonicalizePathForContainment(absPath: string): string { + const resolved = path.resolve(absPath); + try { + return fs.realpathSync(resolved); + } catch (error) { + if (!isEnoentError(error)) { + throw error; + } + } + + const parent = path.dirname(resolved); + if (parent === resolved) { + return resolved; + } + return path.join(canonicalizePathForContainment(parent), path.basename(resolved)); +} + +function toPortablePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function matchesProtectedPathPattern( + pattern: { re: RegExp; src: string }, + cwd: string, + filePath: string, + targetPath: string, +): boolean { + const resolvedCwd = path.resolve(cwd); + const normalizedRaw = normalizePathToken(filePath); + const normalizedTarget = toPortablePath(targetPath); + const relativeTarget = toPortablePath(path.relative(resolvedCwd, targetPath)); + const candidates = new Set([ + normalizedRaw, + normalizedTarget, + path.basename(normalizedTarget), + ]); + if (relativeTarget.length && !relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { + candidates.add(relativeTarget); + } + return [...candidates].some((candidate) => candidate.length > 0 && pattern.re.test(candidate)); +} + +function resolveWritableTargetPath( + cwd: string, + filePath: string, + sandboxConfig?: WorkerSandboxConfig, +): { targetPath: string | null; error?: string } { + const targetPath = path.resolve(cwd, filePath); + const realCwd = canonicalizePathForContainment(cwd); + const realTargetPath = canonicalizePathForContainment(targetPath); + const allowedRoots = resolveAllowedWriteRoots(cwd, sandboxConfig).map((allowedRoot) => + canonicalizePathForContainment(allowedRoot), + ); + const withinAllowedRoots = allowedRoots.some((allowedRoot) => isWithinDir(allowedRoot, realTargetPath)); + if (!withinAllowedRoots) { + return { + targetPath: null, + error: `Write path is outside allowed roots: ${filePath}`, + }; + } + if (sandboxConfig) { + const protectedPatterns = compileSandbox(sandboxConfig).protected; + const matchedPattern = protectedPatterns.find((pattern) => + matchesProtectedPathPattern(pattern, realCwd, filePath, realTargetPath), + ); + if (matchedPattern) { + return { + targetPath: null, + error: `Write path matches protected file pattern: ${matchedPattern.src}`, + }; + } + } + return { targetPath }; +} + function normalizePathToken(token: string): string { return token.trim().replace(/^[("'`]+/, "").replace(/[)"'`,;]+$/, ""); } @@ -201,9 +285,41 @@ function tokenizeCommand(command: string): string[] { return tokens; } -function collectPathReferences(command: string, cwd: string): Array<{ raw: string; resolved: string }> { - const refs = new Map(); - const addPath = (rawValue: string) => { +function looksLikePathToken(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("~") || + value.startsWith(".") || + value.includes("/") + ); +} + +function splitCommandSegments(tokens: string[]): string[][] { + const segments: string[][] = []; + let current: string[] = []; + for (const token of tokens) { + const normalized = normalizePathToken(token); + if (normalized === "|" || normalized === "||" || normalized === "&&" || normalized === ";" || normalized === "&") { + if (current.length > 0) segments.push(current); + current = []; + continue; + } + current.push(token); + } + if (current.length > 0) segments.push(current); + return segments; +} + +function collectPathReferences(command: string, cwd: string): PathReference[] { + const refs = new Map(); + const accessPriority: Record = { + unknown: 0, + read: 1, + write: 2, + }; + const addPath = (rawValue: string, access: PathAccessMode = "unknown") => { const normalizedRaw = normalizePathToken(rawValue); if (!normalizedRaw.length) return; if (normalizedRaw === "/dev/null") return; @@ -217,7 +333,10 @@ function collectPathReferences(command: string, cwd: string): Array<{ raw: strin : normalizedRaw; const resolved = path.resolve(cwd, expandedPath); const key = `${normalizedRaw}::${resolved}`; - if (!refs.has(key)) refs.set(key, { raw: normalizedRaw, resolved }); + const existing = refs.get(key); + if (!existing || accessPriority[access] > accessPriority[existing.access]) { + refs.set(key, { raw: normalizedRaw, resolved, access }); + } }; for (const token of tokenizeCommand(command)) { @@ -228,20 +347,74 @@ function collectPathReferences(command: string, cwd: string): Array<{ raw: strin if (value.includes("=") && !value.startsWith("./") && !value.startsWith("../") && !value.startsWith("/") && !value.startsWith(".")) { continue; } - if ( - value.startsWith("/") || - value.startsWith("./") || - value.startsWith("../") || - value.startsWith("~") || - value.startsWith(".") || - value.includes("/") - ) { - addPath(value); + if (looksLikePathToken(value)) { + addPath(value, "unknown"); } } for (const match of command.matchAll(/(?:^|[\s;|&])(?:\d?>|>>)([^\s'";|&<>]+)/g)) { - if (match[1]) addPath(match[1]); + if (match[1]) addPath(match[1], "write"); + } + + const markOperands = (commandName: string, args: string[]) => { + const normalizedCommand = path.basename(commandName).toLowerCase(); + const pathOperands = args + .map((value) => normalizePathToken(value)) + .filter((value) => value.length > 0 && !value.startsWith("-") && looksLikePathToken(value)); + if (!pathOperands.length) return; + + switch (normalizedCommand) { + case "cp": + case "install": + case "ln": { + if (pathOperands.length >= 2) { + pathOperands.slice(0, -1).forEach((value) => addPath(value, "read")); + addPath(pathOperands[pathOperands.length - 1]!, "write"); + } + return; + } + case "mv": + case "rm": + case "mkdir": + case "touch": + case "chmod": + case "chown": + case "patch": + case "truncate": + pathOperands.forEach((value) => addPath(value, "write")); + return; + case "tee": + pathOperands.forEach((value) => addPath(value, "write")); + return; + case "sed": + if (args.some((value) => value === "-i" || value.startsWith("-i"))) { + pathOperands.forEach((value) => addPath(value, "write")); + } + return; + case "perl": + if (args.some((value) => value.startsWith("-i"))) { + pathOperands.forEach((value) => addPath(value, "write")); + } + return; + default: + return; + } + }; + + for (const segment of splitCommandSegments(tokenizeCommand(command))) { + let commandIndex = 0; + while ( + commandIndex < segment.length + && normalizePathToken(segment[commandIndex] ?? "").includes("=") + && !looksLikePathToken(normalizePathToken(segment[commandIndex] ?? "")) + ) { + commandIndex += 1; + } + if (commandIndex >= segment.length) continue; + const commandName = normalizePathToken(segment[commandIndex] ?? ""); + const args = segment.slice(commandIndex + 1); + if (!commandName.length) continue; + markOperands(commandName, args); } return [...refs.values()]; @@ -266,6 +439,7 @@ export function checkWorkerSandbox( } const safeMatch = compiled.safe.some((re) => re.test(command)); + const commandMutates = bashCommandLikelyMutates(command); // 2. Validate file paths against allowedPaths (absolute + relative) const rootResolved = path.resolve(projectRoot); @@ -273,7 +447,9 @@ export function checkWorkerSandbox( for (const entry of pathRefs) { const p = entry.raw; const resolved = entry.resolved; - if (resolved.startsWith("/usr/bin/") || resolved.startsWith("/usr/local/bin/") || resolved === "/dev/null") continue; + const isSystemExecutablePath = resolved.startsWith("/usr/bin/") || resolved.startsWith("/usr/local/bin/"); + if (resolved === "/dev/null") continue; + if (isSystemExecutablePath && (entry.access === "read" || (!commandMutates && entry.access !== "write"))) continue; const withinAllowed = config.allowedPaths.some((allowed) => { const allowedAbs = path.resolve(projectRoot, allowed); @@ -285,12 +461,13 @@ export function checkWorkerSandbox( } // 3. Check protected files for write-like commands (safe commands do not bypass this) - if (WRITE_COMMAND_RE.test(command)) { + if (commandMutates) { + const protectedRefs = pathRefs.filter((entry) => entry.access !== "read"); for (const { re, src } of compiled.protected) { if (re.test(command)) { return { allowed: false, reason: `Command targets protected file pattern: ${src}` }; } - const targetsProtectedPath = pathRefs.some((entry) => re.test(entry.raw) || re.test(entry.resolved.replace(/\\/g, "/"))); + const targetsProtectedPath = protectedRefs.some((entry) => matchesProtectedPathPattern({ re, src }, projectRoot, entry.raw, entry.resolved)); if (targetsProtectedPath) { return { allowed: false, reason: `Command targets protected file pattern: ${src}` }; } @@ -331,7 +508,7 @@ function createBashTool( .default(120_000) .describe("Timeout in milliseconds (max 600000)"), }), - ...((() => { const a = makeApproval(mode, "bash", Boolean(onApprovalRequest)); return a ? { needsApproval: a } : {}; })()), + ...approvalProp(mode, "bash", Boolean(onApprovalRequest)), execute: async ({ command, timeout }) => { if (requiresTurnMemoryGuard(turnMemoryPolicyState) && bashCommandLikelyMutates(command)) { return { @@ -409,7 +586,7 @@ function createBashTool( } catch (err) { return { stdout: "", - stderr: `Command failed: ${err instanceof Error ? err.message : String(err)}`, + stderr: `Command failed: ${getErrorMessage(err)}`, exitCode: 1, }; } @@ -432,7 +609,7 @@ function createWriteFileTool( file_path: z.string().describe("Path to the file (absolute or relative to project root)"), content: z.string().describe("The full content to write"), }), - ...((() => { const a = makeApproval(mode, "write", Boolean(onApprovalRequest)); return a ? { needsApproval: a } : {}; })()), + ...approvalProp(mode, "write", Boolean(onApprovalRequest)), execute: async ({ file_path, content }) => { if (requiresTurnMemoryGuard(turnMemoryPolicyState)) { return { @@ -459,13 +636,11 @@ function createWriteFileTool( } try { - const targetPath = path.resolve(cwd, file_path); - const allowedRoots = resolveAllowedWriteRoots(cwd, sandboxConfig); - const withinAllowedRoots = allowedRoots.some((allowedRoot) => isWithinDir(allowedRoot, targetPath)); - if (!withinAllowedRoots) { + const { targetPath, error } = resolveWritableTargetPath(cwd, file_path, sandboxConfig); + if (!targetPath) { return { success: false, - message: `Write path is outside allowed roots: ${file_path}`, + message: error ?? `Write path is outside allowed roots: ${file_path}`, }; } await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); @@ -474,7 +649,7 @@ function createWriteFileTool( } catch (err) { return { success: false, - message: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, + message: `Error writing file: ${getErrorMessage(err)}`, }; } }, @@ -482,7 +657,9 @@ function createWriteFileTool( } function createEditFileTool( + cwd: string, mode: PermissionMode, + sandboxConfig?: WorkerSandboxConfig, onApprovalRequest?: (request: ToolApprovalRequest) => Promise, turnMemoryPolicyState?: TurnMemoryPolicyState, ) { @@ -491,7 +668,7 @@ function createEditFileTool( "Make a targeted edit to a file by replacing an exact string match with new content. " + "The old_string must appear exactly once in the file unless replace_all is true.", inputSchema: z.object({ - file_path: z.string().describe("Absolute path to the file to edit"), + file_path: z.string().describe("Path to the file (absolute or relative to project root)"), old_string: z.string().describe("The exact string to find and replace"), new_string: z.string().describe("The replacement string"), replace_all: z @@ -500,7 +677,7 @@ function createEditFileTool( .default(false) .describe("Replace all occurrences instead of requiring a unique match"), }), - ...((() => { const a = makeApproval(mode, "write", Boolean(onApprovalRequest)); return a ? { needsApproval: a } : {}; })()), + ...approvalProp(mode, "write", Boolean(onApprovalRequest)), execute: async ({ file_path, old_string, new_string, replace_all }) => { if (requiresTurnMemoryGuard(turnMemoryPolicyState)) { return { @@ -529,17 +706,25 @@ function createEditFileTool( } try { + const { targetPath, error } = resolveWritableTargetPath(cwd, file_path, sandboxConfig); + if (!targetPath) { + return { + success: false, + message: error ?? `Write path is outside allowed roots: ${file_path}`, + }; + } + let content: string; try { - content = await fs.promises.readFile(file_path, "utf-8"); + content = await fs.promises.readFile(targetPath, "utf-8"); } catch { - return { success: false, message: `File not found: ${file_path}` }; + return { success: false, message: `File not found: ${targetPath}` }; } if (!content.includes(old_string)) { return { success: false, - message: `The old_string was not found in ${file_path}`, + message: `The old_string was not found in ${targetPath}`, }; } @@ -550,7 +735,7 @@ function createEditFileTool( return { success: false, message: - `old_string appears multiple times in ${file_path}. ` + + `old_string appears multiple times in ${targetPath}. ` + "Provide more context to make the match unique, or set replace_all to true.", }; } @@ -560,12 +745,12 @@ function createEditFileTool( ? content.split(old_string).join(new_string) : content.replace(old_string, new_string); - await fs.promises.writeFile(file_path, updated, "utf-8"); - return { success: true, message: `Successfully edited ${file_path}` }; + await fs.promises.writeFile(targetPath, updated, "utf-8"); + return { success: true, message: `Successfully edited ${targetPath}` }; } catch (err) { return { success: false, - message: `Error editing file: ${err instanceof Error ? err.message : String(err)}`, + message: `Error editing file: ${getErrorMessage(err)}`, }; } }, @@ -637,7 +822,7 @@ function createListDirTool() { } catch (err) { return { entries: [], - error: `Error listing directory: ${err instanceof Error ? err.message : String(err)}`, + error: `Error listing directory: ${getErrorMessage(err)}`, }; } }, @@ -661,7 +846,7 @@ function createGitStatusTool(cwd: string) { ); return { branch: branch.trim(), status: stdout.trim(), clean: stdout.trim() === "" }; } catch (err) { - return { error: `git status failed: ${err instanceof Error ? err.message : String(err)}` }; + return { error: `git status failed: ${getErrorMessage(err)}` }; } }, }); @@ -694,7 +879,7 @@ function createGitDiffTool(cwd: string) { const truncated = stdout.length > 200_000; return { diff: stdout.slice(0, 200_000), truncated }; } catch (err) { - return { diff: "", error: `git diff failed: ${err instanceof Error ? err.message : String(err)}` }; + return { diff: "", error: `git diff failed: ${getErrorMessage(err)}` }; } }, }); @@ -720,7 +905,7 @@ function createGitLogTool(cwd: string) { const { stdout } = await execFileAsync("git", args, { cwd, timeout: 15_000 }); return { log: stdout.trim() }; } catch (err) { - return { log: "", error: `git log failed: ${err instanceof Error ? err.message : String(err)}` }; + return { log: "", error: `git log failed: ${getErrorMessage(err)}` }; } }, }); @@ -744,13 +929,54 @@ function createAskUserTool(onAskUser?: (question: string) => Promise) { } catch (err) { return { answer: "", - error: `Failed to get user response: ${err instanceof Error ? err.message : String(err)}`, + error: `Failed to get user response: ${getErrorMessage(err)}`, }; } }, }); } +function createExitPlanModeTool( + onApprovalRequest?: (request: ToolApprovalRequest) => Promise, +) { + return tool({ + description: + "Exit plan mode and request user approval to proceed with implementation. " + + "Call this after you have written your plan and are ready for the user to review it. " + + "The user will see a plan approval UI and can approve or reject your plan.", + inputSchema: z.object({ + planDescription: z.string().optional().describe("A summary of the plan for the user to review"), + }), + execute: async ({ planDescription }) => { + if (!onApprovalRequest) { + return { approved: false, message: "No approval handler configured. Stay in plan mode and ask the user to review your plan in chat." }; + } + const summary = planDescription?.trim() || "Plan ready for review."; + let result: ToolApprovalResult; + try { + result = await onApprovalRequest({ + category: "exitPlanMode", + description: summary, + detail: { tool: "exitPlanMode", planContent: summary }, + }); + } catch (err) { + const reason = getErrorMessage(err) || "Approval request failed."; + return { + approved: false, + message: `Plan approval could not be requested. ${reason}`, + }; + } + if (result.approved) { + return { approved: true, message: "User approved the plan. Proceed with implementation." }; + } + const feedback = typeof result.reason === "string" && result.reason.trim().length > 0 + ? result.reason.trim() + : "Please revise your approach and try again."; + return { approved: false, message: `User rejected the plan. ${feedback}` }; + }, + }); +} + function createMemoryUpdateCoreTool( onMemoryUpdateCore: NonNullable ) { @@ -818,7 +1044,7 @@ export function createUniversalToolSet( webSearch: webSearchTool, // Write tools (auto in edit+full-auto, gated in plan) - editFile: createEditFileTool(permissionMode, onApprovalRequest, turnMemoryPolicyState), + editFile: createEditFileTool(cwd, permissionMode, effectiveSandboxConfig, onApprovalRequest, turnMemoryPolicyState), writeFile: createWriteFileTool(cwd, permissionMode, effectiveSandboxConfig, onApprovalRequest, turnMemoryPolicyState), // Bash (auto only in full-auto, gated in plan+edit) @@ -829,6 +1055,10 @@ export function createUniversalToolSet( askUser: createAskUserTool(onAskUser), }; + if (permissionMode === "plan") { + tools.exitPlanMode = createExitPlanModeTool(onApprovalRequest); + } + // Conditionally add memory tools if (memoryService && projectId) { const memTools = createMemoryTools(memoryService, projectId, { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 54f8e4a4e..8d17ef313 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1,11 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawn } from "node:child_process"; -import readline from "node:readline"; import { generateText, streamText } from "ai"; +import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; // --------------------------------------------------------------------------- // vi.hoisted mock state @@ -13,6 +11,12 @@ import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic const mockState = vi.hoisted(() => ({ sessions: new Map(), uuidCounter: 0, + codexThreadCounter: 0, + codexTurnCounter: 0, + codexLineHandler: null as ((line: string) => void) | null, + emitCodexPayload(payload: Record) { + mockState.codexLineHandler?.(JSON.stringify(payload)); + }, nextUuid: () => { mockState.uuidCounter += 1; return `test-uuid-${mockState.uuidCounter}`; @@ -34,7 +38,36 @@ vi.mock("node:crypto", async (importOriginal) => { vi.mock("node:child_process", () => ({ spawn: vi.fn(() => { const proc: any = { - stdin: { write: vi.fn(), end: vi.fn(), writable: true }, + stdin: { + writable: true, + write: vi.fn((line: string) => { + const payload = JSON.parse(line); + if (payload?.id == null || typeof payload?.method !== "string") return true; + + let result: Record = {}; + if (payload.method === "thread/start") { + mockState.codexThreadCounter += 1; + result = { thread: { id: `thread-${mockState.codexThreadCounter}` } }; + } else if (payload.method === "turn/start" || payload.method === "review/start") { + mockState.codexTurnCounter += 1; + result = { turn: { id: `turn-${mockState.codexTurnCounter}` } }; + } else if (payload.method === "skills/list") { + result = { skills: [] }; + } else if (payload.method === "account/rateLimits/read") { + result = { rateLimits: { remaining: 10, limit: 100, resetAt: null } }; + } + + queueMicrotask(() => { + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: payload.id, + result, + }); + }); + return true; + }), + end: vi.fn(), + }, stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, on: vi.fn(), @@ -48,13 +81,21 @@ vi.mock("node:child_process", () => ({ vi.mock("node:readline", () => ({ default: { createInterface: vi.fn(() => ({ - on: vi.fn(), + on: vi.fn((event: string, handler: (line: string) => void) => { + if (event === "line") { + mockState.codexLineHandler = handler; + } + }), close: vi.fn(), [Symbol.asyncIterator]: vi.fn(), })), }, createInterface: vi.fn(() => ({ - on: vi.fn(), + on: vi.fn((event: string, handler: (line: string) => void) => { + if (event === "line") { + mockState.codexLineHandler = handler; + } + }), close: vi.fn(), [Symbol.asyncIterator]: vi.fn(), })), @@ -74,14 +115,17 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ vi.mock("../ai/providerResolver", () => ({ normalizeCliMcpServers: vi.fn(() => ({})), + isModelCliWrapped: vi.fn((modelId: string) => !String(modelId).endsWith("-api")), resolveModel: vi.fn(async () => ({})), resolveProvider: vi.fn(), + buildProviderOptions: vi.fn(() => ({})), })); vi.mock("../ai/tools/universalTools", () => ({ - createUniversalToolSet: vi.fn(() => ({ - tools: {}, - prompts: [], + createUniversalToolSet: vi.fn((): Record => ({ + readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, + grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, + bash: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, })), })); @@ -167,9 +211,10 @@ import { } from "./agentChatService"; import { detectAllAuth } from "../ai/authDetector"; import * as providerResolver from "../ai/providerResolver"; +import { createUniversalToolSet } from "../ai/tools/universalTools"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; -import type { ComputerUseBackendStatus } from "../../../shared/types"; +import type { AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; // --------------------------------------------------------------------------- // Helpers @@ -187,6 +232,10 @@ function createLogger() { } function createMockLaneService() { + const lanes = [ + { id: "lane-1", name: "Primary", laneType: "primary", worktreePath: tmpRoot }, + { id: "lane-2", name: "Selected", laneType: "feature", worktreePath: tmpRoot }, + ]; return { getLaneBaseAndBranch: vi.fn((_laneId: string) => ({ baseRef: "main", @@ -194,7 +243,21 @@ function createMockLaneService() { worktreePath: tmpRoot, laneType: "feature", })), - getLane: vi.fn(() => null), + list: vi.fn(async () => lanes), + ensurePrimaryLane: vi.fn(async () => {}), + create: vi.fn(async ({ name, description, parentLaneId }: { name: string; description?: string; parentLaneId?: string }) => { + const lane = { + id: `lane-${lanes.length + 1}`, + name, + description: description ?? null, + laneType: "feature", + worktreePath: tmpRoot, + parentLaneId: parentLaneId ?? "lane-1", + }; + lanes.push(lane); + return lane; + }), + getLane: vi.fn((laneId: string) => lanes.find((lane) => lane.id === laneId) ?? null), } as any; } @@ -248,70 +311,19 @@ function createMockSessionService() { if (args.resumeCommand !== undefined) row.resumeCommand = args.resumeCommand; } }), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), setResumeCommand: vi.fn((sessionId: string, resumeCommand: string | null) => { const row = sessions.get(sessionId); if (row) { row.resumeCommand = resumeCommand; } }), - setHeadShaStart: vi.fn(), - setHeadShaEnd: vi.fn(), - setLastOutputPreview: vi.fn(), - setSummary: vi.fn(), } as any; } -async function flushPromises(iterations = 4) { - for (let index = 0; index < iterations; index += 1) { - await Promise.resolve(); - } -} - -function getLatestSpawnProc() { - const proc = vi.mocked(spawn).mock.results.at(-1)?.value as any; - expect(proc).toBeTruthy(); - return proc; -} - -function getLatestReader() { - const reader = vi.mocked(readline.createInterface).mock.results.at(-1)?.value as any; - expect(reader).toBeTruthy(); - return reader; -} - -function getReaderLineHandler(reader: any): (line: string) => void { - const lineCall = reader.on.mock.calls.find(([event]: [string]) => event === "line"); - expect(lineCall).toBeTruthy(); - return lineCall[1]; -} - -function writtenPayloads(proc: any): Array> { - return proc.stdin.write.mock.calls.map(([payload]: [string]) => JSON.parse(String(payload).trim())); -} - -async function waitForWrittenMethod(proc: any, method: string) { - return waitForWrittenMethodCount(proc, method, 1); -} - -async function waitForWrittenMethodCount(proc: any, method: string, count: number) { - for (let attempt = 0; attempt < 20; attempt += 1) { - const payloads = writtenPayloads(proc).filter((entry) => entry.method === method); - if (payloads.length >= count) return payloads[count - 1]; - await flushPromises(); - } - throw new Error(`Timed out waiting for request '${method}'. Saw methods: ${writtenPayloads(proc).map((entry) => entry.method).join(", ")}`); -} - -async function completeCodexInitialize(proc: any, lineHandler: (line: string) => void) { - const initialize = await waitForWrittenMethod(proc, "initialize"); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: initialize.id, - result: {}, - })); - await flushPromises(); -} - function createMockProjectConfigService() { return { get: vi.fn(() => ({ @@ -354,6 +366,34 @@ function createService(overrides: Record = {}) { return { service, logger, laneService, sessionService, projectConfigService }; } +function readPersistedChatState(sessionId: string): Record { + return JSON.parse( + fs.readFileSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${sessionId}.json`), "utf8"), + ) as Record; +} + +function writePersistedChatState(sessionId: string, nextState: Record): void { + fs.writeFileSync( + path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${sessionId}.json`), + JSON.stringify(nextState, null, 2), + "utf8", + ); +} + +async function waitForEvent( + events: AgentChatEventEnvelope[], + predicate: (event: AgentChatEventEnvelope) => event is T, +): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const match = events.find(predicate); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Timed out waiting for agent chat event."); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -365,10 +405,12 @@ beforeEach(() => { fs.mkdirSync(path.join(tmpRoot, ".ade", "transcripts", "chat"), { recursive: true }); mockState.sessions.clear(); mockState.uuidCounter = 0; + mockState.codexThreadCounter = 0; + mockState.codexTurnCounter = 0; + mockState.codexLineHandler = null; vi.mocked(streamText).mockReset(); vi.mocked(generateText).mockReset(); vi.mocked(unstable_v2_createSession).mockReset(); - vi.mocked(unstable_v2_resumeSession).mockReset(); vi.mocked(detectAllAuth).mockResolvedValue([]); vi.mocked(providerResolver.resolveModel).mockResolvedValue({} as any); vi.mocked(parseAgentChatTranscript).mockReturnValue([]); @@ -540,16 +582,18 @@ describe("createAgentChatService", () => { expect(session.status).toBe("idle"); }); - it("stores the real runtime model name for Codex GPT-5.4 sessions", async () => { + it("migrates legacy Claude plan mode into interaction mode", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", - provider: "codex", - model: "gpt-5.4-codex", + provider: "claude", + model: "sonnet", + claudePermissionMode: "plan", }); - expect(session.modelId).toBe("openai/gpt-5.4-codex"); - expect(session.model).toBe("gpt-5.4"); + expect(session.interactionMode).toBe("plan"); + expect(session.claudePermissionMode).toBe("default"); + expect(session.permissionMode).toBe("plan"); }); it("sets sessionProfile to workflow by default", async () => { @@ -668,7 +712,7 @@ describe("createAgentChatService", () => { expect(metaFiles.length).toBeGreaterThanOrEqual(1); const persisted = JSON.parse(fs.readFileSync(path.join(chatSessionsDir, metaFiles[0]!), "utf8")); - expect(persisted.version).toBe(1); + expect(persisted.version).toBe(2); expect(persisted.provider).toBe("unified"); }); @@ -760,11 +804,13 @@ describe("createAgentChatService", () => { expect(result.session.executionMode).toBe("parallel"); expect(mockState.sessions.get(result.session.id)?.goal).toBe("Fix the work-tab handoff UI."); - await new Promise((resolve) => setTimeout(resolve, 25)); const transcriptPath = mockState.sessions.get(result.session.id)?.transcriptPath; expect(transcriptPath).toBeTruthy(); - const transcript = fs.readFileSync(String(transcriptPath), "utf8"); - expect(transcript).toContain("Chat handoff from previous session"); + // Wait for the async transcript write to flush (CI runners can be slow) + await vi.waitFor(() => { + const transcript = fs.readFileSync(String(transcriptPath), "utf8"); + expect(transcript).toContain("Chat handoff from previous session"); + }, { timeout: 2000, interval: 50 }); }); it("uses AI-generated handoff summaries when a summary model is available", async () => { @@ -861,54 +907,6 @@ describe("createAgentChatService", () => { ); }); - it("skips memory search for trivial test pings", async () => { - vi.mocked(streamText).mockReturnValue({ - fullStream: (async function* () { - yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; - })(), - } as any); - - const memoryService = { - search: vi.fn(async () => []), - } as any; - const onEvent = vi.fn(); - const { service } = createService({ - memoryService, - onEvent, - computerUseArtifactBrokerService: { - getBackendStatus: vi.fn(() => ({ - backends: [], - localFallback: { - available: false, - detail: "disabled", - supportedKinds: [], - }, - })), - } as any, - }); - const session = await service.createSession({ - laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "this is a test", - }); - - expect(memoryService.search).not.toHaveBeenCalled(); - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - type: "system_notice", - noticeKind: "memory", - }), - }), - ); - }); - it("checks memory and emits a memory notice for coding turns", async () => { vi.mocked(streamText).mockReturnValue({ fullStream: (async function* () { @@ -965,221 +963,7 @@ describe("createAgentChatService", () => { event: expect.objectContaining({ type: "system_notice", noticeKind: "memory", - message: expect.stringContaining("Memory:"), - }), - }), - ); - }); - - it("loads bootstrap memory for non-trivial arbitrary turns even without targeted search", async () => { - vi.mocked(streamText).mockReturnValue({ - fullStream: (async function* () { - yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 2 } }; - })(), - } as any); - - const memoryService = { - search: vi.fn(async () => []), - } as any; - const memoryFilesService = { - buildPromptContext: vi.fn(() => ({ - text: "ADE auto memory bootstrap (generated from promoted project memory):\n- Decision: keep SQLite as the canonical store.", - bootstrapLoaded: true, - topicFilesLoaded: [], - })), - } as any; - const onEvent = vi.fn(); - const { service } = createService({ - memoryService, - memoryFilesService, - onEvent, - computerUseArtifactBrokerService: { - getBackendStatus: vi.fn(() => ({ - backends: [], - localFallback: { - available: false, - detail: "disabled", - supportedKinds: [], - }, - })), - } as any, - }); - const session = await service.createSession({ - laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Take a look at the release flow and tell me what stands out.", - }); - - expect(memoryFilesService.buildPromptContext).toHaveBeenCalled(); - expect(memoryService.search).not.toHaveBeenCalled(); - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - type: "system_notice", - noticeKind: "memory", - message: expect.stringContaining("loaded bootstrap"), - }), - }), - ); - }); - - it("injects generated auto memory bootstrap into coding turns", async () => { - vi.mocked(streamText).mockReturnValue({ - fullStream: (async function* () { - yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 2 } }; - })(), - } as any); - - const memoryService = { - search: vi.fn(async () => []), - } as any; - const memoryFilesService = { - buildPromptContext: vi.fn(() => ({ - text: "ADE auto memory bootstrap (generated from promoted project memory):\n- Convention: keep SQLite as the memory source of truth.", - bootstrapLoaded: true, - topicFilesLoaded: ["conventions.md"], - })), - } as any; - const onEvent = vi.fn(); - const { service } = createService({ - memoryService, - memoryFilesService, - onEvent, - computerUseArtifactBrokerService: { - getBackendStatus: vi.fn(() => ({ - backends: [], - localFallback: { - available: false, - detail: "disabled", - supportedKinds: [], - }, - })), - } as any, - }); - const session = await service.createSession({ - laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Fix the failing memory tests in the desktop app.", - }); - - expect(memoryFilesService.buildPromptContext).toHaveBeenCalled(); - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - type: "system_notice", - noticeKind: "memory", - detail: expect.objectContaining({ - sections: expect.arrayContaining([ - expect.objectContaining({ - title: "Auto memory files", - items: expect.arrayContaining([ - expect.stringContaining(".ade/memory/MEMORY.md"), - expect.stringContaining("conventions.md"), - ]), - }), - ]), - }), - }), - }), - ); - }); - - it("captures explicit user instructions into memory", async () => { - vi.mocked(streamText).mockReturnValue({ - fullStream: (async function* () { - yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 1 } }; - })(), - } as any); - - const savedMemory = { - id: "memory-saved-1", - projectId: "test-project", - scope: "project", - scopeOwnerId: null, - tier: 2, - category: "convention", - content: "Convention: we always use pnpm, not npm, in this repo.", - importance: "high", - sourceSessionId: "test-uuid-1", - sourcePackKey: null, - createdAt: "2026-03-25T10:00:00.000Z", - updatedAt: "2026-03-25T10:00:00.000Z", - lastAccessedAt: "2026-03-25T10:00:00.000Z", - accessCount: 0, - observationCount: 0, - status: "promoted", - agentId: "test-uuid-1", - confidence: 1, - promotedAt: "2026-03-25T10:00:00.000Z", - sourceRunId: null, - sourceType: "user", - sourceId: "chat:auto-capture", - fileScopePattern: null, - pinned: false, - accessScore: 0, - compositeScore: 0.9, - writeGateReason: null, - }; - const memoryService = { - search: vi.fn(async () => []), - writeMemory: vi.fn(() => ({ - accepted: true, - memory: savedMemory, - deduped: false, - })), - } as any; - const onEvent = vi.fn(); - const { service } = createService({ - memoryService, - onEvent, - computerUseArtifactBrokerService: { - getBackendStatus: vi.fn(() => ({ - backends: [], - localFallback: { - available: false, - detail: "disabled", - supportedKinds: [], - }, - })), - } as any, - }); - const session = await service.createSession({ - laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Please remember we always use pnpm, not npm, in this repo.", - }); - - expect(memoryService.writeMemory).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "project", - category: "convention", - sourceType: "user", - }), - ); - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - type: "system_notice", - noticeKind: "memory", - message: expect.stringContaining("Captured explicit user instruction"), + message: expect.stringContaining("Checked memory"), }), }), ); @@ -1249,33 +1033,319 @@ describe("createAgentChatService", () => { }); }); - // -------------------------------------------------------------------------- - // getSessionSummary - // -------------------------------------------------------------------------- - - describe("getSessionSummary", () => { - it("returns null for unknown session id", async () => { + describe("ensureIdentitySession", () => { + it("hosts canonical identity sessions on the primary lane", async () => { const { service } = createService(); - const summary = await service.getSessionSummary("nonexistent-id"); - expect(summary).toBeNull(); - }); - it("returns null for empty session id", async () => { - const { service } = createService(); - const summary = await service.getSessionSummary(""); - expect(summary).toBeNull(); - }); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); - it("returns null for whitespace-only session id", async () => { - const { service } = createService(); - const summary = await service.getSessionSummary(" "); - expect(summary).toBeNull(); + expect(session.laneId).toBe("lane-1"); + expect(session.permissionMode).toBe("plan"); }); - it("returns summary for an existing session", async () => { - const { service } = createService(); - const created = await service.createSession({ - laneId: "lane-1", + it("does not reuse a foreign-lane identity session and retires it during migration", async () => { + const { service, sessionService } = createService(); + + const legacy = await service.createSession({ + laneId: "lane-2", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + identityKey: "cto", + }); + + const canonical = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + expect(canonical.id).not.toBe(legacy.id); + expect(canonical.laneId).toBe("lane-1"); + expect(sessionService.get(legacy.id)?.status).toBe("ended"); + + const reused = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + expect(reused.id).toBe(canonical.id); + expect(reused.laneId).toBe("lane-1"); + }); + }); + + describe("identity continuity", () => { + it("replays persisted continuity context after resuming an identity session", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall <= 2) { + yield { + type: "system", + subtype: "init", + session_id: `sdk-session-${streamCall}`, + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Acknowledged" }], + 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-1", + setPermissionMode, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + identityKey: "cto", + }); + + const persisted = readPersistedChatState(session.id); + writePersistedChatState(session.id, { + ...persisted, + continuitySummary: "- Keep the OpenClaw bridge runtime state in machine-local cache.", + continuitySummaryUpdatedAt: new Date().toISOString(), + recentConversationEntries: [ + { role: "user", text: "What lane should frontend use?" }, + { role: "assistant", text: "Use the primary-hosted coordinator first." }, + ], + }); + + const resumed = createService().service; + await resumed.resumeSession({ sessionId: session.id }); + await new Promise((resolve) => setTimeout(resolve, 20)); + send.mockClear(); + + const result = await resumed.runSessionTurn({ + sessionId: session.id, + text: "What should we do next?", + timeoutMs: 15_000, + }); + + expect(result.sessionId).toBe(session.id); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Continuity Summary")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Keep the OpenClaw bridge runtime state in machine-local cache.")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("User: What lane should frontend use?")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Use the primary-hosted coordinator first.")); + }); + + it("persists a continuity snapshot and prewarms a fresh Claude session after identity session reset errors", async () => { + const primarySend = vi.fn().mockResolvedValue(undefined); + const recoverySend = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let primaryStreamCall = 0; + const primarySession = { + send: primarySend, + stream: vi.fn(() => (async function* () { + primaryStreamCall += 1; + if (primaryStreamCall === 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", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "Partial answer" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + throw new Error("session expired"); + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + }; + const recoverySession = { + send: recoverySend, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-2", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-2", + setPermissionMode, + }; + vi.mocked(unstable_v2_createSession) + .mockReturnValueOnce(primarySession as any) + .mockReturnValueOnce(recoverySession as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + identityKey: "cto", + }); + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "Please keep the OpenClaw bridge state private.", + timeoutMs: 15_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const persisted = readPersistedChatState(session.id); + expect(result.outputText).toContain("Partial answer"); + expect(persisted.sdkSessionId).toBe("sdk-session-2"); + expect(persisted.continuitySummary).toContain("Recent continuity snapshot:"); + expect(persisted.continuitySummary).toContain("User: Please keep the OpenClaw bridge state private."); + expect(persisted.continuitySummary).toContain("Assistant: Partial answer"); + expect(unstable_v2_createSession).toHaveBeenCalledTimes(2); + expect(recoverySend).toHaveBeenCalledWith("System initialization check. Respond with only the word READY."); + }); + + it("keeps continuity compaction scoped to identity sessions", async () => { + const primarySend = vi.fn().mockResolvedValue(undefined); + const recoverySend = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let primaryStreamCall = 0; + const primarySession = { + send: primarySend, + stream: vi.fn(() => (async function* () { + primaryStreamCall += 1; + if (primaryStreamCall === 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", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "Partial answer" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + throw new Error("session expired"); + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + }; + const recoverySession = { + send: recoverySend, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-2", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-2", + setPermissionMode, + }; + vi.mocked(unstable_v2_createSession) + .mockReturnValueOnce(primarySession as any) + .mockReturnValueOnce(recoverySession as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "Please keep the bridge state private.", + timeoutMs: 15_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const persisted = readPersistedChatState(session.id); + expect(result.outputText).toContain("Partial answer"); + expect(persisted.continuitySummary).toBeUndefined(); + expect(unstable_v2_createSession).toHaveBeenCalledTimes(2); + }); + }); + + // -------------------------------------------------------------------------- + // getSessionSummary + // -------------------------------------------------------------------------- + + describe("getSessionSummary", () => { + it("returns null for unknown session id", async () => { + const { service } = createService(); + const summary = await service.getSessionSummary("nonexistent-id"); + expect(summary).toBeNull(); + }); + + it("returns null for empty session id", async () => { + const { service } = createService(); + const summary = await service.getSessionSummary(""); + expect(summary).toBeNull(); + }); + + it("returns null for whitespace-only session id", async () => { + const { service } = createService(); + const summary = await service.getSessionSummary(" "); + expect(summary).toBeNull(); + }); + + it("returns summary for an existing session", async () => { + const { service } = createService(); + const created = await service.createSession({ + laneId: "lane-1", provider: "unified", model: "", modelId: "anthropic/claude-sonnet-4-6-api", @@ -1540,27 +1610,10 @@ describe("createAgentChatService", () => { const updated = await service.updateSession({ sessionId: session.id, - unifiedPermissionMode: "full-auto", - }); - - expect(updated.unifiedPermissionMode).toBe("full-auto"); - }); - - it("keeps the Codex wrapper id while updating the runtime model name", async () => { - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.4-codex", - }); - - const updated = await service.updateSession({ - sessionId: session.id, - modelId: "openai/gpt-5.4-codex", + permissionMode: "full-auto", }); - expect(updated.modelId).toBe("openai/gpt-5.4-codex"); - expect(updated.model).toBe("gpt-5.4"); + expect(updated.permissionMode).toBe("full-auto"); }); it("updates computer use policy", async () => { @@ -1584,221 +1637,6 @@ describe("createAgentChatService", () => { expect(updated.computerUse!.mode).toBe("enabled"); }); - - it("resets an idle Claude SDK session when permission mode changes", async () => { - const close = vi.fn(); - vi.mocked(unstable_v2_createSession).mockReturnValue({ - send: vi.fn(async () => {}), - stream: vi.fn(() => (async function* () { - yield { type: "system", subtype: "init", session_id: "sdk-session-1" } as any; - yield { type: "result" } as any; - })()), - close, - sessionId: "sdk-session-1", - } as any); - - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - await flushPromises(8); - - await service.updateSession({ - sessionId: session.id, - claudePermissionMode: "bypassPermissions", - }); - - expect(close).toHaveBeenCalled(); - }); - - it("rebinds the current Codex thread on the next turn after settings change", async () => { - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.4-codex", - }); - - const persistedPath = path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`); - const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8")); - persisted.threadId = "thread-codex-1"; - fs.writeFileSync(persistedPath, JSON.stringify(persisted, null, 2), "utf8"); - - const resumePromise = service.resumeSession({ sessionId: session.id }); - const proc = getLatestSpawnProc(); - const reader = getLatestReader(); - const lineHandler = getReaderLineHandler(reader); - await completeCodexInitialize(proc, lineHandler); - const initialThreadResume = await waitForWrittenMethodCount(proc, "thread/resume", 1); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: initialThreadResume.id, - result: {}, - })); - await resumePromise; - - await service.updateSession({ - sessionId: session.id, - codexSandbox: "danger-full-access", - }); - - await service.sendMessage({ - sessionId: session.id, - text: "second turn", - }); - - await flushPromises(); - const threadResume = await waitForWrittenMethodCount(proc, "thread/resume", 2); - expect(threadResume.params.threadId).toBe("thread-codex-1"); - expect(threadResume.params.sandbox).toBe("danger-full-access"); - - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: threadResume.id, - result: {}, - })); - await flushPromises(); - - const secondTurnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: secondTurnStart.id, - result: { turn: { id: "turn-2" } }, - })); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - method: "turn/completed", - params: { turn: { id: "turn-2", status: "completed" } }, - })); - await flushPromises(8); - }); - }); - - describe("codex runtime continuity", () => { - it("captures thread identity from thread/started notifications", async () => { - const { service, sessionService } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.4-codex", - }); - - const turnPromise = service.runSessionTurn({ - sessionId: session.id, - text: "hello codex", - timeoutMs: 15_000, - }); - - await flushPromises(); - const proc = getLatestSpawnProc(); - const reader = getLatestReader(); - const lineHandler = getReaderLineHandler(reader); - await completeCodexInitialize(proc, lineHandler); - - const threadStart = await waitForWrittenMethod(proc, "thread/start"); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: threadStart.id, - result: {}, - })); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - method: "thread/started", - params: { thread: { id: "thread-from-notification" } }, - })); - - await flushPromises(); - const turnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); - - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: turnStart.id, - result: { turn: { id: "turn-notify-1" } }, - })); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - method: "turn/completed", - params: { turn: { id: "turn-notify-1", status: "completed" } }, - })); - - const result = await turnPromise; - expect(result.threadId).toBe("thread-from-notification"); - expect(sessionService.setResumeCommand).toHaveBeenCalledWith( - session.id, - "chat:codex:thread-from-notification", - ); - - const persisted = JSON.parse( - fs.readFileSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`), "utf8"), - ); - expect(persisted.threadId).toBe("thread-from-notification"); - }); - - it("captures thread identity from nested raw Codex agent-message payloads", async () => { - const { service, sessionService } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.4-codex", - }); - - const firstTurnPromise = service.runSessionTurn({ - sessionId: session.id, - text: "hello codex", - timeoutMs: 15_000, - }); - - await flushPromises(); - const proc = getLatestSpawnProc(); - const reader = getLatestReader(); - const lineHandler = getReaderLineHandler(reader); - await completeCodexInitialize(proc, lineHandler); - - const threadStart = await waitForWrittenMethod(proc, "thread/start"); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: threadStart.id, - result: {}, - })); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - method: "codex/event/agent_message_content_delta", - params: { - msg: { - id: "agent-message-1", - conversationId: "thread-from-raw-msg", - content: "hello from nested raw event", - }, - }, - })); - - await flushPromises(); - const firstTurnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: firstTurnStart.id, - result: { turn: { id: "turn-raw-1" } }, - })); - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - method: "turn/completed", - params: { turn: { id: "turn-raw-1", status: "completed" } }, - })); - - const firstResult = await firstTurnPromise; - expect(firstResult.threadId).toBe("thread-from-raw-msg"); - expect(sessionService.setResumeCommand).toHaveBeenCalledWith( - session.id, - "chat:codex:thread-from-raw-msg", - ); - const persisted = JSON.parse( - fs.readFileSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`), "utf8"), - ); - expect(persisted.threadId).toBe("thread-from-raw-msg"); - }); }); // -------------------------------------------------------------------------- @@ -1912,44 +1750,341 @@ describe("createAgentChatService", () => { const sessions = await service.listSessions(); expect(sessions.length).toBe(2); }); - }); - - // -------------------------------------------------------------------------- - // setComputerUseArtifactBrokerService - // -------------------------------------------------------------------------- - - describe("setComputerUseArtifactBrokerService", () => { - it("accepts a broker service without throwing", () => { - const { service } = createService(); - const mockBroker = { - getBackendStatus: vi.fn(() => null), - ingest: vi.fn(), - }; - - expect(() => service.setComputerUseArtifactBrokerService(mockBroker as any)).not.toThrow(); - }); - }); - - // -------------------------------------------------------------------------- - // warmupModel - // -------------------------------------------------------------------------- - describe("warmupModel", () => { - it("does nothing for unknown session id", async () => { - const { service } = createService(); - // Should not throw - await expect( - service.warmupModel({ sessionId: "no-such-session", modelId: "anthropic/claude-sonnet-4-6-api" }), - ).resolves.toBeUndefined(); - }); + it("deduplicates Codex compatibility item notifications", async () => { + const events: Array<{ type: string; tool?: string; itemId?: string }> = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push({ + type: event.event.type, + tool: "tool" in event.event ? event.event.tool : undefined, + itemId: "itemId" in event.event ? event.event.itemId : undefined, + }); + }, + }); - it("does nothing for non-anthropic model", async () => { - const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Search the repo", + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "item-1", + type: "dynamicToolCall", + tool: "search_files", + arguments: { query: "AgentChatPane" }, + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "codex/event/item_started", + params: { + turnId: "turn-1", + item: { + id: "item-1", + type: "dynamicToolCall", + tool: "search_files", + arguments: { query: "AgentChatPane" }, + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "item-1", + type: "dynamicToolCall", + tool: "search_files", + success: true, + contentItems: [{ text: "Found matches" }], + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "codex/event/item_completed", + params: { + turnId: "turn-1", + item: { + id: "item-1", + type: "dynamicToolCall", + tool: "search_files", + success: true, + contentItems: [{ text: "Found matches" }], + }, + }, + }); + + const toolCalls = events.filter((event) => event.type === "tool_call" && event.itemId === "item-1"); + const toolResults = events.filter((event) => event.type === "tool_result" && event.itemId === "item-1"); + + expect(toolCalls).toHaveLength(1); + expect(toolResults).toHaveLength(1); + }); + + it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { + const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + if (event.event.type !== "text") return; + textEvents.push({ + text: event.event.text, + itemId: event.event.itemId, + turnId: event.event.turnId, + }); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Say hello", + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + itemId: "msg-1", + delta: "Hello", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + delta: "Hello", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + itemId: "msg-1", + delta: " world", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + delta: " world", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + delta: "Hello world", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + }, + }, + }); + + expect(textEvents).toEqual([ + { + text: "Hello world", + turnId: "turn-1", + }, + ]); + }); + + it("ignores stale Codex lifecycle notifications from a foreign turn", async () => { + const events: Array<{ type: string; turnId?: string; text?: string }> = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push({ + type: event.event.type, + turnId: "turnId" in event.event ? event.event.turnId ?? undefined : undefined, + text: "text" in event.event ? event.event.text : undefined, + }); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.resumeSession({ sessionId: session.id }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { + turn: { + id: "turn-1", + }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-stale", + status: "completed", + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/aborted", + params: { + turnId: "turn-stale", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + delta: "Still streaming", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-1", + status: "completed", + }, + }, + }); + + expect(events.filter((event) => event.type === "done").map((event) => event.turnId)).toEqual(["turn-1"]); + expect(events.filter((event) => event.type === "status" && event.turnId === "turn-stale")).toHaveLength(0); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "text", turnId: "turn-1", text: "Still streaming" }), + ])); + }); + + it("switches the Claude SDK session into plan mode before a plan turn", 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-1", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Plan ready" }], + 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-1", + setPermissionMode, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + interactionMode: "plan", + }); + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "Outline the implementation only.", + interactionMode: "plan", + }); + + expect(result.outputText).toContain("Plan ready"); + expect(setPermissionMode).toHaveBeenCalledWith("plan"); + expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); + }); + }); + + // -------------------------------------------------------------------------- + // setComputerUseArtifactBrokerService + // -------------------------------------------------------------------------- + + describe("setComputerUseArtifactBrokerService", () => { + it("accepts a broker service without throwing", () => { + const { service } = createService(); + const mockBroker = { + getBackendStatus: vi.fn(() => null), + ingest: vi.fn(), + }; + + expect(() => service.setComputerUseArtifactBrokerService(mockBroker as any)).not.toThrow(); + }); + }); + + // -------------------------------------------------------------------------- + // warmupModel + // -------------------------------------------------------------------------- + + describe("warmupModel", () => { + it("does nothing for unknown session id", async () => { + const { service } = createService(); + // Should not throw + await expect( + service.warmupModel({ sessionId: "no-such-session", modelId: "anthropic/claude-sonnet-4-6-api" }), + ).resolves.toBeUndefined(); + }); + + it("does nothing for non-anthropic model", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", }); // A non-anthropic-cli model should be a no-op @@ -1972,28 +2107,7 @@ describe("createAgentChatService", () => { it("returns an array for codex provider", async () => { const { service } = createService(); - const modelsPromise = service.getAvailableModels({ provider: "codex" }); - await flushPromises(); - - const proc = getLatestSpawnProc(); - const reader = getLatestReader(); - const lineHandler = getReaderLineHandler(reader); - await completeCodexInitialize(proc, lineHandler); - const modelList = await waitForWrittenMethod(proc, "model/list"); - - lineHandler(JSON.stringify({ - jsonrpc: "2.0", - id: modelList.id, - result: { - data: [{ - id: "openai/gpt-5.4-codex", - displayName: "GPT-5.4 Codex", - isDefault: true, - }], - }, - })); - - const models = await modelsPromise; + const models = await service.getAvailableModels({ provider: "codex" }); expect(Array.isArray(models)).toBe(true); }); @@ -2032,4 +2146,290 @@ describe("createAgentChatService", () => { ).rejects.toThrow(/not found/i); }); }); + + // -------------------------------------------------------------------------- + // Session creation edge cases + // -------------------------------------------------------------------------- + + describe("session creation edge cases", () => { + it("applies automationId and automationRunId when surface is automation", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + surface: "automation", + automationId: "auto-1", + automationRunId: "run-1", + }); + + expect(session.surface).toBe("automation"); + expect(session.automationId).toBe("auto-1"); + expect(session.automationRunId).toBe("run-1"); + }); + + it("creates a codex session with specified model", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + expect(session.provider).toBe("codex"); + expect(session.status).toBe("idle"); + }); + + it("persists capabilityMode when provided", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + capabilityMode: "cto", + } as any); + + // capabilityMode may be resolved to a fallback if not fully supported + expect(session.capabilityMode).toBeDefined(); + }); + + it("uses default execution mode for new sessions", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + // executionMode defaults to null or undefined for new sessions + expect(session.executionMode == null).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + // Session status transitions + // -------------------------------------------------------------------------- + + describe("session status transitions", () => { + it("session starts with idle status", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + expect(session.status).toBe("idle"); + }); + + it("session has null completion initially", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + expect(session.completion).toBeNull(); + }); + }); + + // -------------------------------------------------------------------------- + // Interaction mode handling + // -------------------------------------------------------------------------- + + describe("interaction mode", () => { + it("defaults interaction mode to null or undefined", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + expect(session.interactionMode == null).toBe(true); + }); + + it("persists plan interaction mode for Claude sessions via claudePermissionMode", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + claudePermissionMode: "plan", + }); + + // Plan interaction mode is derived from claudePermissionMode for Claude sessions + expect(session.interactionMode).toBe("plan"); + expect(session.permissionMode).toBe("plan"); + }); + + it("maps claude plan permission mode to interaction mode plan", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + claudePermissionMode: "plan", + }); + + expect(session.interactionMode).toBe("plan"); + expect(session.claudePermissionMode).toBe("default"); + }); + }); + + // -------------------------------------------------------------------------- + // Resume and error recovery + // -------------------------------------------------------------------------- + + describe("resumeSession", () => { + it("resumes a disposed session back to idle", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.dispose({ sessionId: session.id }); + const resumed = await service.resumeSession({ sessionId: session.id }); + + expect(resumed.id).toBe(session.id); + expect(sessionService.reopen).toHaveBeenCalledWith(session.id); + }); + + it("throws when resuming an unknown session", async () => { + const { service } = createService(); + await expect( + service.resumeSession({ sessionId: "unknown-session-id" }), + ).rejects.toThrow(/not found/i); + }); + }); + + // -------------------------------------------------------------------------- + // Interrupt + // -------------------------------------------------------------------------- + + describe("interrupt", () => { + it("throws when interrupting an unknown session", async () => { + const { service } = createService(); + await expect( + service.interrupt({ sessionId: "unknown-session-id" }), + ).rejects.toThrow(/not found/i); + }); + }); + + // -------------------------------------------------------------------------- + // steer + // -------------------------------------------------------------------------- + + describe("steer", () => { + it("throws when steering an unknown session", async () => { + const { service } = createService(); + await expect( + service.steer({ + sessionId: "unknown-session-id", + text: "refocus on the main bug", + }), + ).rejects.toThrow(/not found/i); + }); + }); + + // -------------------------------------------------------------------------- + // approveToolUse + // -------------------------------------------------------------------------- + + describe("approveToolUse", () => { + it("throws when approving for an unknown session", async () => { + const { service } = createService(); + await expect( + service.approveToolUse({ + sessionId: "unknown-session-id", + itemId: "unknown-item-id", + decision: "accept", + }), + ).rejects.toThrow(/not found/i); + }); + + it("exits unified plan mode after a one-time plan approval", async () => { + const events: AgentChatEventEnvelope[] = []; + let requestApproval: + | ((args: { + category: "exitPlanMode"; + description: string; + detail?: Record; + }) => Promise<{ approved: boolean; decision?: string; reason: string }>) + | null = null; + + vi.mocked(createUniversalToolSet).mockImplementation((_cwd: string, options: any) => { + requestApproval = options.onApprovalRequest; + return {}; + }); + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + yield { type: "start-step", stepNumber: 0 }; + if (!requestApproval) { + throw new Error("Unified approval handler was not captured."); + } + const approvalPromise = requestApproval({ + category: "exitPlanMode", + description: "Plan ready for approval", + detail: { planContent: "1. Inspect\n2. Implement" }, + }); + yield { type: "tool-call", toolName: "ExitPlanMode", toolCallId: "tool-exit-plan" }; + const approvalResult = await approvalPromise; + yield { type: "tool-result", toolName: "ExitPlanMode", toolCallId: "tool-exit-plan", result: approvalResult }; + yield { type: "text-delta", textDelta: "Implementation complete." }; + yield { type: "finish", usage: {} }; + })(), + }) as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "openai/gpt-5.4", + modelId: "openai/gpt-5.4", + permissionMode: "plan", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Review the plan and implement it after approval.", + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => { + if (event.event.type !== "approval_request") return false; + const detail = event.event.detail as { request?: { kind?: string } } | undefined; + return detail?.request?.kind === "plan_approval"; + }, + ); + + await service.approveToolUse({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + }); + + await sendPromise; + + const updated = await service.getSessionSummary(session.id); + expect(updated?.permissionMode).toBe("edit"); + expect(updated?.unifiedPermissionMode).toBe("edit"); + }); + }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 71f8152a1..08a37da77 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -21,6 +21,7 @@ type ClaudeV2Session = { stream: () => AsyncGenerator; close: () => void; readonly sessionId: string; + setPermissionMode?: (mode: AgentChatClaudePermissionMode) => Promise; }; import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { @@ -29,17 +30,6 @@ import { shouldFlushBufferedAssistantTextForEvent, type BufferedAssistantText, } from "./chatTextBatching"; -import { - createRecoveryState, - canAttemptRecovery, - getRecoveryBackoffMs, - markRecoveryAttempt, - markRecoveryComplete, - markRecoverySuccess, - isRecoverableError, - createRecoveryNoticeEvent, - type RecoveryState, -} from "./sessionRecovery"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createSessionService } from "../sessions/sessionService"; @@ -63,7 +53,6 @@ import type { AgentChatCodexConfigSource, AgentChatCodexSandbox, AgentChatCreateArgs, - AgentChatNoticeDetail, AgentChatDisposeArgs, AgentChatExecutionMode, AgentChatEvent, @@ -72,6 +61,7 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatIdentityKey, + AgentChatInteractionMode, AgentChatInterruptArgs, AgentChatModelInfo, AgentChatProvider, @@ -96,15 +86,12 @@ import type { CtoCapabilityMode, } from "../../../shared/types"; import { - getRuntimeModelRefForDescriptor, getDefaultModelDescriptor, getModelById, getAvailableModels as getRegistryModels, - isModelProviderGroup, listModelDescriptorsForProvider, MODEL_REGISTRY, resolveModelAlias, - resolveModelDescriptorForProvider, resolveProviderGroupForModel, type ModelDescriptor, } from "../../../shared/modelRegistry"; @@ -125,15 +112,7 @@ import { } from "../ai/providerRuntimeHealth"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import type { - createMemoryService, - Memory, - MemoryCategory, - MemoryImportance, - WriteMemoryResult, -} from "../memory/memoryService"; -import { resolveAgentMemoryWritePolicy } from "../memory/unifiedMemoryService"; -import type { ProjectMemoryFilesService } from "../memory/memoryFilesService"; +import type { createMemoryService, Memory } from "../memory/memoryService"; import type { createCtoStateService } from "../cto/ctoStateService"; import type { createWorkerAgentService } from "../cto/workerAgentService"; import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; @@ -172,8 +151,14 @@ type PersistedClaudeMessage = { content: string; }; +type PersistedRecentConversationEntry = { + role: "user" | "assistant"; + text: string; + turnId?: string; +}; + type PersistedChatState = { - version: 1; + version: 1 | 2; sessionId: string; laneId: string; provider: AgentChatProvider; @@ -182,11 +167,13 @@ type PersistedChatState = { sessionProfile?: "light" | "workflow"; reasoningEffort?: string | null; executionMode?: AgentChatExecutionMode | null; + interactionMode?: AgentChatInteractionMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; codexApprovalPolicy?: AgentChatCodexApprovalPolicy; codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; unifiedPermissionMode?: AgentChatUnifiedPermissionMode; + permissionMode?: AgentChatSession["permissionMode"]; identityKey?: AgentChatIdentityKey; surface?: AgentChatSurface; automationId?: string | null; @@ -197,6 +184,11 @@ type PersistedChatState = { threadId?: string; sdkSessionId?: string; messages?: PersistedClaudeMessage[]; + recentConversationEntries?: PersistedRecentConversationEntry[]; + continuitySummary?: string | null; + continuitySummaryUpdatedAt?: string | null; + preferredExecutionLaneId?: string | null; + selectedExecutionLaneId?: string | null; updatedAt: string; }; @@ -207,7 +199,7 @@ type PendingRpc = { type PendingCodexApproval = { requestId: string | number; - kind: "command" | "file_change" | "permissions" | "structured_question"; + kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval"; request?: PendingInputRequest; permissions?: Record | null; }; @@ -230,12 +222,13 @@ type CodexRuntime = { activeTurnId: string | null; startedTurnId: string | null; threadResumed: boolean; - pendingThreadRebind: boolean; - threadIdWaiters: Set<(threadId?: string) => void>; itemTurnIdByItemId: Map; commandOutputByItemId: Map; fileDeltaByItemId: Map; fileChangesByItemId: Map>; + agentMessageScopeByTurn: Map; + agentMessageTextByTurn: Map; + recentNotificationKeys: Set; request: (method: string, params?: unknown) => Promise; notify: (method: string, params?: unknown) => void; sendResponse: (id: string | number, result: unknown) => void; @@ -249,7 +242,7 @@ type ClaudeRuntime = { sdkSessionId: string | null; activeQuery: import("@anthropic-ai/claude-agent-sdk").Query | null; v2Session: ClaudeV2Session | null; - /** Active V2 stream generator for the current turn. */ + /** Single stream generator kept alive across turns (never closed by for-await). */ v2StreamGen: AsyncGenerator | null; /** Resolves when the subprocess is initialized (system:init received). */ v2WarmupDone: Promise | null; @@ -264,13 +257,13 @@ type ClaudeRuntime = { pendingSteers: string[]; approvals: Map; interrupted: boolean; - /** Set when a V2 session setting changes mid-turn; flushed when idle. */ + /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ pendingSessionReset?: boolean; turnMemoryPolicyState: TurnMemoryPolicyState | null; }; type PendingUnifiedApproval = { - category: "bash" | "write" | "askUser"; + category: "bash" | "write" | "askUser" | "exitPlanMode"; request?: PendingInputRequest; resolve: (response: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }) => void; }; @@ -283,7 +276,7 @@ type UnifiedRuntime = { activeTurnId: string | null; permissionMode: PermissionMode; pendingApprovals: Map; - approvalOverrides: Set<"bash" | "write">; + approvalOverrides: Set<"bash" | "write" | "exitPlanMode">; pendingSteers: string[]; interrupted: boolean; resolvedModel: LanguageModel; @@ -298,8 +291,7 @@ function asRecord(value: unknown): Record | null { : null; } -/** Pick the first non-empty trimmed string from a list of unknowns. Used for turn, thread, and item IDs. */ -function pickCodexStringId(...values: unknown[]): string | undefined { +function pickCodexTurnId(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value !== "string") continue; const trimmed = value.trim(); @@ -308,105 +300,129 @@ function pickCodexStringId(...values: unknown[]): string | undefined { return undefined; } -function pickCodexText(...values: unknown[]): string | undefined { - for (const value of values) { - if (typeof value !== "string") continue; - if (value.length > 0) return value; - } - return undefined; +function extractCodexTurnId(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) return undefined; + const nestedTurn = asRecord(record.turn); + return pickCodexTurnId(record.turnId, record.turn_id, nestedTurn?.id); } -function collectCodexPayloadRecords(value: unknown): Array> { - const records: Array> = []; - const queue: unknown[] = [value]; - const seen = new Set>(); +function readCodexNotificationItemId(params: Record): string | null { + const nestedItem = asRecord(params.item); + return pickCodexTurnId(params.itemId, nestedItem?.id) ?? null; +} - while (queue.length > 0) { - const next = queue.shift(); - const record = asRecord(next); - if (!record || seen.has(record)) continue; - seen.add(record); - records.push(record); +function codexNotificationDedupKey(payload: JsonRpcEnvelope): string | null { + const method = typeof payload.method === "string" ? payload.method : ""; + const params = asRecord(payload.params) ?? {}; - for (const key of ["msg", "payload", "data", "event", "item", "turn", "thread"]) { - const nested = asRecord(record[key]); - if (nested && !seen.has(nested)) { - queue.push(nested); - } - } + if (method === "item/started" || method === "codex/event/item_started") { + const itemId = readCodexNotificationItemId(params); + return itemId ? `item_started:${itemId}` : null; } - return records; + if (method === "item/completed" || method === "codex/event/item_completed") { + const itemId = readCodexNotificationItemId(params); + return itemId ? `item_completed:${itemId}` : null; + } + + if (method === "turn/aborted" || method === "codex/event/turn_aborted") { + const turnId = extractCodexTurnId(params); + return turnId ? `turn_aborted:${turnId}` : null; + } + + return null; } -function extractCodexTurnId(value: unknown): string | undefined { - for (const record of collectCodexPayloadRecords(value)) { - const nestedTurn = asRecord(record.turn); - const nestedItem = asRecord(record.item); - const turnId = pickCodexStringId( - record.turnId, - record.turn_id, - nestedTurn?.id, - nestedTurn?.turnId, - nestedTurn?.turn_id, - nestedItem?.turnId, - nestedItem?.turn_id, - ); - if (turnId) return turnId; +function shouldSkipDuplicateCodexNotification(runtime: CodexRuntime, payload: JsonRpcEnvelope): boolean { + const key = codexNotificationDedupKey(payload); + if (!key) return false; + if (runtime.recentNotificationKeys.has(key)) return true; + runtime.recentNotificationKeys.add(key); + if (runtime.recentNotificationKeys.size > 2048) { + runtime.recentNotificationKeys.clear(); + runtime.recentNotificationKeys.add(key); } - return undefined; + return false; } -function extractCodexThreadId(value: unknown): string | undefined { - for (const record of collectCodexPayloadRecords(value)) { - const nestedThread = asRecord(record.thread); - const threadId = pickCodexStringId( - record.threadId, - record.thread_id, - record.conversationId, - nestedThread?.id, - nestedThread?.threadId, - nestedThread?.thread_id, - ); - if (threadId) return threadId; +function discardBufferedAssistantText(managed: ManagedChatSession): void { + const buffered = managed.bufferedText; + if (!buffered) return; + if (buffered.timer) { + clearTimeout(buffered.timer); } - return undefined; + managed.bufferedText = null; + managed.activeAssistantMessageId = null; } -function extractCodexItemId(value: unknown): string | undefined { - for (const record of collectCodexPayloadRecords(value)) { - const nestedItem = asRecord(record.item); - const itemId = pickCodexStringId( - record.itemId, - record.item_id, - nestedItem?.id, - nestedItem?.itemId, - nestedItem?.item_id, - ); - if (itemId) return itemId; +function resetAssistantMessageStream(managed: ManagedChatSession): void { + managed.activeAssistantMessageId = null; +} + +function ensureAssistantMessageId( + managed: ManagedChatSession, + event: Extract, +): Extract { + const explicitMessageId = event.messageId?.trim() || null; + if (explicitMessageId) { + managed.activeAssistantMessageId = explicitMessageId; + return explicitMessageId === event.messageId ? event : { ...event, messageId: explicitMessageId }; } - return undefined; + + const activeMessageId = managed.activeAssistantMessageId ?? randomUUID(); + managed.activeAssistantMessageId = activeMessageId; + return { ...event, messageId: activeMessageId }; } -function extractCodexTextPayload(value: unknown): string | undefined { - for (const record of collectCodexPayloadRecords(value)) { - const text = pickCodexText( - record.delta, - record.text, - record.content, - record.message, - ); - if (text) return text; +function ensureLogicalItemId(event: T): T { + const explicitLogicalItemId = event.logicalItemId?.trim() || null; + if (explicitLogicalItemId) { + return explicitLogicalItemId === event.logicalItemId ? event : { ...event, logicalItemId: explicitLogicalItemId }; } - return undefined; + + const fallbackLogicalItemId = event.itemId.trim(); + if (!fallbackLogicalItemId.length) return event; + return { ...event, logicalItemId: fallbackLogicalItemId }; } -function shiftPendingSteer(queue: string[]): string | null { - while (queue.length > 0) { - const next = (queue.shift() ?? "").trim(); - if (next.length > 0) return next; +function isCurrentCodexLifecycleTurn( + runtime: CodexRuntime, + turnId: string | null | undefined, +): boolean { + const activeTurnId = runtime.activeTurnId ?? runtime.startedTurnId; + if (!activeTurnId || !turnId) return true; + return activeTurnId === turnId; +} + +function normalizeCodexAssistantDelta( + runtime: CodexRuntime, + args: { + turnId?: string; + itemId?: string; + delta: string; + }, +): string | null { + const turnId = args.turnId?.trim() || null; + if (!turnId || args.itemId) { + return args.delta; } - return null; + + const knownText = runtime.agentMessageTextByTurn.get(turnId) ?? ""; + if (!knownText.length) { + runtime.agentMessageTextByTurn.set(turnId, args.delta); + return args.delta; + } + + if (args.delta.startsWith(knownText)) { + const suffix = args.delta.slice(knownText.length); + runtime.agentMessageTextByTurn.set(turnId, args.delta); + return suffix.length ? suffix : null; + } + + const nextText = `${knownText}${args.delta}`; + runtime.agentMessageTextByTurn.set(turnId, nextText); + return args.delta; } function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true } | { ready: false; reason: string } { @@ -452,6 +468,8 @@ type ManagedChatSession = { autoTitleSeed: string | null; autoTitleStage: "none" | "initial" | "final"; autoTitleInFlight: boolean; + summaryInFlight: boolean; + activeAssistantMessageId: string | null; lastActivitySignature: string | null; bufferedReasoning: { text: string; @@ -461,6 +479,7 @@ type ManagedChatSession = { } | null; previewTextBuffer: { text: string; + messageId?: string; turnId?: string; itemId?: string; } | null; @@ -470,8 +489,20 @@ type ManagedChatSession = { text: string; turnId?: string; }>; + continuitySummary: string | null; + continuitySummaryUpdatedAt: string | null; + continuitySummaryInFlight: boolean; + preferredExecutionLaneId: string | null; + selectedExecutionLaneId: string | null; + localPendingInputs: Map; + responseText?: string | null; + }) => void; + }>; eventSequence: number; - recoveryState: RecoveryState; }; type AgentChatTranscriptEntry = { @@ -523,6 +554,8 @@ type PreparedSendMessage = { visibleText: string; attachments: AgentChatFileRef[]; reasoningEffort?: string | null; + interactionMode?: AgentChatInteractionMode | null; + onDispatched?: () => void; }; type ResolvedChatConfig = { @@ -620,10 +653,25 @@ function resolveSessionModelDescriptor(session: AgentChatSession): ModelDescript if (session.modelId) { return getModelById(session.modelId) ?? resolveModelAlias(session.modelId) ?? null; } - return resolveModelDescriptorForProvider( - session.provider === "claude" ? resolveClaudeCliModel(session.model) : session.model, - isModelProviderGroup(session.provider) ? session.provider : undefined, - ) ?? null; + + if (session.provider === "claude") { + const resolvedClaudeModel = resolveClaudeCliModel(session.model); + return listModelDescriptorsForProvider("claude").find((descriptor) => + descriptor.sdkModelId === resolvedClaudeModel + || descriptor.shortId === session.model + || descriptor.id === session.model, + ) ?? null; + } + + if (session.provider === "codex") { + return listModelDescriptorsForProvider("codex").find((descriptor) => + descriptor.sdkModelId === session.model + || descriptor.shortId === session.model + || descriptor.id === session.model, + ) ?? null; + } + + return getModelById(session.model) ?? resolveModelAlias(session.model) ?? null; } function sessionSupportsReasoning(session: AgentChatSession): boolean { @@ -748,12 +796,7 @@ function mapApprovalDecisionForCodex(decision: AgentChatApprovalDecision): "acce } function isPlanningApprovalGuarded(managed: ManagedChatSession): boolean { - const s = managed.session; - if (s.provider === "claude") return s.claudePermissionMode === "plan"; - if (s.provider === "unified") return s.unifiedPermissionMode === "plan"; - // Codex has no direct "plan" equivalent; treat untrusted+read-only as plan mode - if (s.provider === "codex") return s.codexApprovalPolicy === "untrusted" && s.codexSandbox === "read-only"; - return false; + return managed.session.permissionMode === "plan"; } function buildPlanningApprovalViolation(toolName: string): string { @@ -845,8 +888,33 @@ function resolveModelIdFromStoredValue( ): string | undefined { const normalized = model.trim().toLowerCase(); if (!normalized.length) return undefined; - const providerGroup = isModelProviderGroup(providerHint) ? providerHint : undefined; - return resolveModelDescriptorForProvider(normalized, providerGroup)?.id; + + const aliasMatch = resolveModelAlias(normalized); + if (aliasMatch) { + if (providerHint === "codex" && !(aliasMatch.family === "openai" && aliasMatch.isCliWrapped)) return undefined; + if (providerHint === "claude" && !(aliasMatch.family === "anthropic" && aliasMatch.isCliWrapped)) return undefined; + if (providerHint === "unified" && aliasMatch.isCliWrapped) return undefined; + return aliasMatch.id; + } + + const matches = MODEL_REGISTRY.filter( + (entry) => + entry.id.toLowerCase() === normalized + || entry.shortId.toLowerCase() === normalized + || entry.sdkModelId.toLowerCase() === normalized + ); + if (!matches.length) return undefined; + + let preferred: ModelDescriptor | undefined; + if (providerHint === "codex") { + preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "openai"); + } else if (providerHint === "claude") { + preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "anthropic"); + } else if (providerHint === "unified") { + preferred = matches.find((entry) => !entry.isCliWrapped); + } + + return preferred?.id ?? matches[0]?.id; } function fallbackModelForProvider(provider: AgentChatProvider): string { @@ -855,12 +923,6 @@ function fallbackModelForProvider(provider: AgentChatProvider): string { return DEFAULT_UNIFIED_MODEL_ID; } -function fallbackModelIdForProvider(provider: AgentChatProvider): string { - if (provider === "codex") return DEFAULT_CODEX_DESCRIPTOR?.id ?? "openai/gpt-5.4-codex"; - if (provider === "claude") return DEFAULT_CLAUDE_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-4-6"; - return DEFAULT_UNIFIED_MODEL_ID; -} - function readProviderParentItemId(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as Record; @@ -960,6 +1022,18 @@ function buildExecutionModeDirective( return null; } +function buildClaudeInteractionModeDirective( + mode: AgentChatInteractionMode | null | undefined, + provider: AgentChatProvider, +): string | null { + if (provider !== "claude" || mode !== "plan") return null; + return [ + "[ADE launch directive]", + "You are in plan mode for this turn.", + "Stay inspect-only: analyze the request, outline the implementation, surface risks, and do not make edits or run commands.", + ].join("\n"); +} + function composeLaunchDirectives(baseText: string, directives: Array): string { const filtered = directives .map((directive) => (typeof directive === "string" ? directive.trim() : "")) @@ -1094,6 +1168,7 @@ function activityForToolName( // Permission mapping functions are shared with the orchestrator/mission system. // Delegate to the single source of truth in permissionMapping.ts. import { + mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; @@ -1102,13 +1177,21 @@ function codexPolicyArgs(policy: ReturnType): Recor return policy ? { approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox } : {}; } +function mapToUnifiedPermissionMode(mode: string | undefined): PermissionMode | undefined { + if (mode === "default" || mode === "config-toml") return "edit"; + if (mode === "plan" || mode === "edit" || mode === "full-auto") return mode; + return undefined; +} + const PLAN_STEP_STATUS_MAP: Record = { completed: "completed", inProgress: "in_progress", failed: "failed", }; +const VALID_PERMISSION_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); const VALID_EXECUTION_MODES = new Set(["focused", "parallel", "subagents", "teams"]); +const VALID_INTERACTION_MODES = new Set(["default", "plan"]); const VALID_CLAUDE_PERMISSION_MODES = new Set(["default", "plan", "acceptEdits", "bypassPermissions"]); const VALID_CODEX_APPROVAL_POLICIES = new Set(["untrusted", "on-request", "on-failure", "never"]); const VALID_CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); @@ -1121,6 +1204,10 @@ function normalizePersistedEnum(value: unknown, validSet: Set< return validSet.has(trimmed) ? trimmed as T : undefined; } +function normalizePersistedPermissionMode(value: unknown): AgentChatSession["permissionMode"] | undefined { + return normalizePersistedEnum(value, VALID_PERMISSION_MODES); +} + function normalizePersistedClaudePermissionMode(value: unknown): AgentChatClaudePermissionMode | undefined { return normalizePersistedEnum(value, VALID_CLAUDE_PERMISSION_MODES); } @@ -1141,54 +1228,213 @@ function normalizePersistedUnifiedPermissionMode(value: unknown): AgentChatUnifi return normalizePersistedEnum(value, VALID_UNIFIED_PERMISSION_MODES); } +function legacyPermissionModeToClaudePermissionMode( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatClaudePermissionMode | undefined { + if (!mode) return undefined; + return mapPermissionToClaude(mode); +} + +type AgentChatClaudeAccessMode = Exclude; + +function normalizeClaudeAccessMode(value: AgentChatClaudePermissionMode | undefined): AgentChatClaudeAccessMode | undefined { + if (value === "default" || value === "acceptEdits" || value === "bypassPermissions") { + return value; + } + return undefined; +} + +function resolveSessionClaudeInteractionMode( + session: Pick, +): AgentChatInteractionMode { + return session.interactionMode + ?? (session.claudePermissionMode === "plan" ? "plan" : undefined) + ?? (session.permissionMode === "plan" ? "plan" : undefined) + ?? "default"; +} + +function resolveSessionClaudeAccessMode( + session: Pick, + fallback: AgentChatClaudePermissionMode, +): AgentChatClaudeAccessMode { + return normalizeClaudeAccessMode(session.claudePermissionMode) + ?? normalizeClaudeAccessMode(legacyPermissionModeToClaudePermissionMode(session.permissionMode)) + ?? normalizeClaudeAccessMode(fallback) + ?? "default"; +} + +function legacyPermissionModeToCodexApprovalPolicy( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatCodexApprovalPolicy | undefined { + if (!mode) return undefined; + if (mode === "config-toml") return undefined; + return mapPermissionToCodex(mode)?.approvalPolicy; +} + +function legacyPermissionModeToCodexSandbox( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatCodexSandbox | undefined { + if (!mode) return undefined; + if (mode === "config-toml") return undefined; + return mapPermissionToCodex(mode)?.sandbox; +} + +function legacyPermissionModeToCodexConfigSource( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatCodexConfigSource | undefined { + if (!mode) return undefined; + return mode === "config-toml" ? "config-toml" : "flags"; +} + +function legacyPermissionModeToUnifiedPermissionMode( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatUnifiedPermissionMode | undefined { + if (!mode) return undefined; + return mode === "default" || mode === "config-toml" ? "edit" : mapToUnifiedPermissionMode(mode); +} + +function syncLegacyPermissionMode(session: Pick< + AgentChatSession, + "provider" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" +>): AgentChatSession["permissionMode"] | undefined { + if (session.provider === "claude") { + if (session.interactionMode === "plan") { + return "plan"; + } + switch (normalizeClaudeAccessMode(session.claudePermissionMode)) { + case "default": + return "default"; + case "acceptEdits": + return "edit"; + case "bypassPermissions": + return "full-auto"; + default: + return undefined; + } + } + + if (session.provider === "codex") { + if (session.codexConfigSource === "config-toml") return "config-toml"; + if (session.codexApprovalPolicy === "never" && session.codexSandbox === "danger-full-access") return "full-auto"; + if (session.codexApprovalPolicy === "on-failure" && session.codexSandbox === "workspace-write") return "edit"; + if (session.codexApprovalPolicy === "untrusted" && session.codexSandbox === "read-only") return "plan"; + return undefined; + } + + switch (session.unifiedPermissionMode) { + case "plan": + case "edit": + case "full-auto": + return session.unifiedPermissionMode; + default: + return undefined; + } +} + +function applyLegacyPermissionModeToNativeControls( + session: Pick< + AgentChatSession, + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" + >, + mode: AgentChatSession["permissionMode"] | undefined, +): void { + session.permissionMode = mode; + if (!mode) return; + + if (session.provider === "claude") { + session.interactionMode = mode === "plan" ? "plan" : "default"; + session.claudePermissionMode = normalizeClaudeAccessMode(legacyPermissionModeToClaudePermissionMode(mode)) ?? "default"; + return; + } + + if (session.provider === "codex") { + session.codexApprovalPolicy = legacyPermissionModeToCodexApprovalPolicy(mode); + session.codexSandbox = legacyPermissionModeToCodexSandbox(mode); + session.codexConfigSource = legacyPermissionModeToCodexConfigSource(mode); + return; + } + + session.unifiedPermissionMode = legacyPermissionModeToUnifiedPermissionMode(mode); +} + +function hydrateNativePermissionControls( + session: Pick< + AgentChatSession, + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" + >, +): void { + if (session.provider === "claude") { + session.interactionMode = resolveSessionClaudeInteractionMode(session); + session.claudePermissionMode = resolveSessionClaudeAccessMode(session, "default"); + } else if (session.provider === "codex") { + session.codexApprovalPolicy = session.codexApprovalPolicy ?? legacyPermissionModeToCodexApprovalPolicy(session.permissionMode); + session.codexSandbox = session.codexSandbox ?? legacyPermissionModeToCodexSandbox(session.permissionMode); + session.codexConfigSource = session.codexConfigSource ?? legacyPermissionModeToCodexConfigSource(session.permissionMode); + } else { + session.unifiedPermissionMode = session.unifiedPermissionMode ?? legacyPermissionModeToUnifiedPermissionMode(session.permissionMode); + } + + session.permissionMode = syncLegacyPermissionMode(session); +} + function resolveSessionClaudePermissionMode( - session: Pick, + session: Pick, fallback: AgentChatClaudePermissionMode, -): AgentChatClaudePermissionMode { - return session.claudePermissionMode ?? fallback; +): AgentChatClaudeAccessMode { + return resolveSessionClaudeAccessMode(session, fallback); } function resolveSessionCodexApprovalPolicy( - session: Pick, + session: Pick, fallback: AgentChatCodexApprovalPolicy, ): AgentChatCodexApprovalPolicy { - return session.codexApprovalPolicy ?? fallback; + return session.codexApprovalPolicy + ?? legacyPermissionModeToCodexApprovalPolicy(session.permissionMode) + ?? fallback; } function resolveSessionCodexSandbox( - session: Pick, + session: Pick, fallback: AgentChatCodexSandbox, ): AgentChatCodexSandbox { - return session.codexSandbox ?? fallback; + return session.codexSandbox + ?? legacyPermissionModeToCodexSandbox(session.permissionMode) + ?? fallback; } function resolveSessionCodexConfigSource( - session: Pick, + session: Pick, ): AgentChatCodexConfigSource { - return session.codexConfigSource ?? "flags"; + return session.codexConfigSource + ?? legacyPermissionModeToCodexConfigSource(session.permissionMode) + ?? "flags"; } function resolveSessionUnifiedPermissionMode( - session: Pick, + session: Pick, fallback: AgentChatUnifiedPermissionMode, ): AgentChatUnifiedPermissionMode { - return session.unifiedPermissionMode ?? fallback; + return session.unifiedPermissionMode + ?? legacyPermissionModeToUnifiedPermissionMode(session.permissionMode) + ?? fallback; } function normalizeSessionNativePermissionControls( session: Pick< AgentChatSession, - "provider" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" >, config: ResolvedChatConfig, ): void { if (session.provider === "claude") { + session.interactionMode = resolveSessionClaudeInteractionMode(session); session.claudePermissionMode = resolveSessionClaudePermissionMode(session, config.claudePermissionMode); delete session.codexApprovalPolicy; delete session.codexSandbox; delete session.codexConfigSource; delete session.unifiedPermissionMode; } else if (session.provider === "codex") { + delete session.interactionMode; session.codexConfigSource = resolveSessionCodexConfigSource(session); if (session.codexConfigSource === "config-toml") { delete session.codexApprovalPolicy; @@ -1200,12 +1446,15 @@ function normalizeSessionNativePermissionControls( delete session.claudePermissionMode; delete session.unifiedPermissionMode; } else { + delete session.interactionMode; session.unifiedPermissionMode = resolveSessionUnifiedPermissionMode(session, config.unifiedPermissionMode); delete session.claudePermissionMode; delete session.codexApprovalPolicy; delete session.codexSandbox; delete session.codexConfigSource; } + + session.permissionMode = syncLegacyPermissionMode(session); } function normalizePersistedExecutionMode(value: unknown): AgentChatExecutionMode | undefined { @@ -1214,6 +1463,10 @@ function normalizePersistedExecutionMode(value: unknown): AgentChatExecutionMode return VALID_EXECUTION_MODES.has(trimmed) ? trimmed as AgentChatExecutionMode : undefined; } +function normalizePersistedInteractionMode(value: unknown): AgentChatInteractionMode | undefined { + return normalizePersistedEnum(value, VALID_INTERACTION_MODES); +} + function normalizePersistedComputerUse(value: unknown): ComputerUsePolicy { return normalizeComputerUsePolicy(value, createDefaultComputerUsePolicy()); } @@ -1288,6 +1541,17 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { return provider === "codex" || provider === "claude" ? "full_mcp" : "fallback"; } +function guardedIdentityPermissionModeForProvider(provider: AgentChatProvider): AgentChatSession["permissionMode"] { + return "plan"; +} + +function normalizeIdentityPermissionMode( + mode: AgentChatSession["permissionMode"] | undefined, + provider: AgentChatProvider, +): AgentChatSession["permissionMode"] { + return mode === "plan" ? "plan" : guardedIdentityPermissionModeForProvider(provider); +} + function isLightweightSession(session: Pick): boolean { return session.sessionProfile === "light"; } @@ -1305,7 +1569,6 @@ export function createAgentChatService(args: { transcriptsDir: string; projectId?: string; memoryService?: ReturnType | null; - memoryFilesService?: Pick | null; fileService?: ReturnType | null; episodicSummaryService?: EpisodicSummaryService | null; ctoStateService?: ReturnType | null; @@ -1334,7 +1597,6 @@ export function createAgentChatService(args: { transcriptsDir, projectId, memoryService, - memoryFilesService, fileService, episodicSummaryService, ctoStateService, @@ -1394,29 +1656,12 @@ export function createAgentChatService(args: { totalHits: number; injectedCount: number; includedProcedure: boolean; - bootstrapLoaded: boolean; - topicFilesLoaded: string[]; }; type AutoMemoryTurnPlan = { classification: AutoMemoryTurnClassification; contextText: string; telemetry: AutoMemoryTurnTelemetry; - selectedEntries: Array<{ - scope: "project" | "agent"; - category: string; - snippet: string; - pinned: boolean; - tier: number | null; - }>; - }; - - type AutoCapturedMemoryCandidate = { - category: Extract; - content: string; - importance: MemoryImportance; - writeMode: "default" | "strict"; - reason: string; }; const EMPTY_MEMORY_TELEMETRY: AutoMemoryTurnTelemetry = { @@ -1426,8 +1671,6 @@ export function createAgentChatService(args: { totalHits: 0, injectedCount: 0, includedProcedure: false, - bootstrapLoaded: false, - topicFilesLoaded: [], }; const ensureSubagentSnapshotMap = (sessionId: string): Map => { @@ -1445,19 +1688,10 @@ export function createAgentChatService(args: { return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; }; - const AUTO_MEMORY_MUTATION_VERB_RE = /\b(?:fix|debug|investigat(?:e|ing|ion)|implement|refactor|patch|edit|write|add|remove|rename|update|change|run|reproduce)\b/i; - const AUTO_MEMORY_CODE_TARGET_RE = /\b(?:file|files|code|app|renderer|component|service|hook|prompt box|composer|thread|memory|chat|model|sandbox|approval|permission|setting|settings|bug|error|exception|stack trace|crash|regression|build|compile|lint|typecheck|test|tests|ui|layout|tsx?|jsx?|json|css|styles?)\b/i; - const AUTO_MEMORY_TOOLCHAIN_RE = /\b(?:unit tests?|integration tests?|e2e tests?|test suite|test failure|failing tests?|vitest|jest|playwright|cypress|npm test|pnpm test|yarn test|build failure|compile error|lint error|typecheck)\b/i; - const AUTO_MEMORY_PROCEDURE_HINT_RE = /\b(?:procedure|workflow|steps?|checklist|runbook|playbook|automate|finalize)\b/i; + const AUTO_MEMORY_REQUIRED_RE = /\b(?:fix|debug|investigat(?:e|ing|ion)|implement|refactor|patch|edit|write|add|remove|rename|update|change|test(?:s|ing)?|failing|error|exception|stack trace|crash|bug|diff|pull request|regression|build|compile|lint|typecheck)\b/i; const AUTO_MEMORY_SOFT_RE = /\b(?:explain|why|how|walk through|summari[sz]e|context|overview|review|plan|brainstorm|design|architecture|tradeoff|decision|pattern|convention|gotcha)\b/i; - const AUTO_MEMORY_META_RE = /^(?:hi|hello|hey|thanks|thank you|ok(?:ay)?|cool|sounds good|nice|what model are you|who are you|are you there|can you help|test(?:ing)?|what|why|lol|yep|nah|yeah|sure|ping|help|yo)\b/i; - const AUTO_MEMORY_TRIVIAL_TEST_RE = /^(?:(?:this|it)\s+is\s+)?(?:just\s+)?test(?:ing)?[.!?]*$/i; + const AUTO_MEMORY_META_RE = /^(?:hi|hello|hey|thanks|thank you|ok(?:ay)?|cool|sounds good|nice|what model are you|who are you|are you there|can you help)\b/i; const AUTO_MEMORY_FILE_PATH_RE = /(?:^|\s)(?:\/|\.{1,2}\/|[A-Za-z]:\\|[A-Za-z0-9_.-]+\/)[^\s]+\.(?:ts|tsx|js|jsx|json|md|yml|yaml|py|go|rs|java|rb|sh)\b/i; - const AUTO_MEMORY_EXPLICIT_SAVE_RE = /\b(?:remember(?:\s+this|\s+that)?|please remember|keep in mind|note that)\b/i; - const AUTO_MEMORY_PREFERENCE_SAVE_RE = /\b(?:i prefer|my preference is|please keep(?: the)? responses?|prefer responses?|keep responses?)\b/i; - const AUTO_MEMORY_CONVENTION_SAVE_RE = /\b(?:we use|we always use|always use|never use|do not use|don't use|our convention is|repo convention|team convention)\b/i; - const AUTO_MEMORY_DECISION_SAVE_RE = /\b(?:decision:|we decided|decided to|we chose|chose to)\b/i; - const AUTO_MEMORY_GOTCHA_SAVE_RE = /\b(?:avoid|pitfall|gotcha|breaks?|fails?|failure|regression|will fail|causes?)\b/i; const CLAUDE_MUTATING_TOOL_RE = /\b(?:bash|write|edit|multiedit|notebookedit)\b/; const CHAT_MEMORY_GUARD_MESSAGE = "Search memory before mutating files or running mutating commands for this turn."; const CLAUDE_MUTATING_BASH_RE = /\b(?:rm|mv|cp|mkdir|touch|chmod|chown|patch|install|uninstall|add|remove|upgrade|apply|commit|rebase|merge|reset|checkout|switch|restore|sed\s+-i|perl\s+-i)\b|>>?|tee\b/i; @@ -1467,48 +1701,25 @@ export function createAgentChatService(args: { attachmentCount = 0, ): AutoMemoryTurnClassification => { const trimmed = promptText.trim(); - if (trimmed.length < 20) return "none"; + if (trimmed.length < 12) return "none"; if (trimmed.startsWith("/")) return "none"; if (/^before context compaction runs\b/i.test(trimmed)) return "none"; if (/^review this conversation and persist\b/i.test(trimmed)) return "none"; - if (AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return "none"; - if (trimmed.split(/\s+/).length <= 3 && !AUTO_MEMORY_CODE_TARGET_RE.test(trimmed) && !AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return "none"; if (attachmentCount > 0) return "required"; if (/```/.test(trimmed) || AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return "required"; - if (AUTO_MEMORY_TOOLCHAIN_RE.test(trimmed)) return "required"; - if (AUTO_MEMORY_MUTATION_VERB_RE.test(trimmed) && AUTO_MEMORY_CODE_TARGET_RE.test(trimmed)) return "required"; + if (AUTO_MEMORY_REQUIRED_RE.test(trimmed)) return "required"; if (AUTO_MEMORY_SOFT_RE.test(trimmed)) return "soft"; - if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 60) return "none"; + if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 80) return "none"; return "none"; }; - /** Returns true for any non-trivial prompt that should get the bootstrap memory context. */ - const shouldLoadAutoMemoryBootstrap = ( - promptText: string, - attachmentCount = 0, - ): boolean => { - if (attachmentCount > 0) return true; - const trimmed = promptText.trim(); - if (trimmed.length < 18) return false; - if (trimmed.startsWith("/")) return false; - if (/^before context compaction runs\b/i.test(trimmed)) return false; - if (/^review this conversation and persist\b/i.test(trimmed)) return false; - if (AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return false; - if (trimmed.split(/\s+/).length <= 3 && !AUTO_MEMORY_CODE_TARGET_RE.test(trimmed) && !AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return false; - if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 60) return false; - return true; - }; - const selectAutoMemoryEntries = ( memories: Memory[], - promptText: string, maxEntries = 4, ): Memory[] => { const seen = new Set(); - const includeProcedure = AUTO_MEMORY_PROCEDURE_HINT_RE.test(promptText); return memories .filter((memory) => AUTO_MEMORY_CATEGORY_ALLOWLIST.has(String(memory.category ?? "").trim())) - .filter((memory) => memory.category !== "procedure" || includeProcedure) .filter((memory) => { if (seen.has(memory.id)) return false; seen.add(memory.id); @@ -1524,33 +1735,16 @@ export function createAgentChatService(args: { const buildAutoMemorySystemNotice = (plan: AutoMemoryTurnPlan): { message: string; - detail: AgentChatNoticeDetail; + detail: string; } | null => { - const hasAutoMemoryFiles = plan.telemetry.bootstrapLoaded || plan.telemetry.topicFilesLoaded.length > 0; - if (!plan.telemetry.searched && !hasAutoMemoryFiles) return null; - const message = plan.telemetry.searched - ? (plan.telemetry.injectedCount > 0 - ? `Memory: ${plan.telemetry.injectedCount} relevant entr${plan.telemetry.injectedCount === 1 ? "y" : "ies"} injected` - : "Memory: searched, no relevant entries") - : `Memory: loaded bootstrap${plan.telemetry.topicFilesLoaded.length > 0 ? ` + ${plan.telemetry.topicFilesLoaded.length} topic file${plan.telemetry.topicFilesLoaded.length === 1 ? "" : "s"}` : ""}`; - const detail: AgentChatNoticeDetail = { - summary: plan.telemetry.searched - ? message - : "ADE loaded the generated project memory bootstrap for this non-trivial turn even though targeted memory search was not required.", - sections: hasAutoMemoryFiles - ? [{ - title: "Auto memory files", - items: [ - ...(plan.telemetry.bootstrapLoaded - ? ["Loaded the generated .ade/memory/MEMORY.md bootstrap index."] - : []), - ...(plan.telemetry.topicFilesLoaded.length > 0 - ? [`Loaded topic files: ${plan.telemetry.topicFilesLoaded.join(", ")}.`] - : []), - ], - }] - : undefined, - }; + if (!plan.telemetry.searched) return null; + const message = `Checked memory: ${plan.telemetry.totalHits} hit${plan.telemetry.totalHits === 1 ? "" : "s"}, injected ${plan.telemetry.injectedCount} relevant entr${plan.telemetry.injectedCount === 1 ? "y" : "ies"}`; + const detail = [ + `Policy: ${plan.classification}`, + `Project hits: ${plan.telemetry.projectHits}`, + `Agent hits: ${plan.telemetry.agentHits}`, + ...(plan.telemetry.includedProcedure ? ["Included procedure memory in the injected set."] : []), + ].join("\n"); return { message, detail }; }; @@ -1579,275 +1773,17 @@ export function createAgentChatService(args: { return { message, detail }; }; - const splitAutoMemoryCaptureClauses = (promptText: string): string[] => { - const normalized = promptText.replace(/```[\s\S]*?```/g, " "); - const segments = normalized - .split(/\r?\n+/) - .flatMap((line) => line.split(/(?<=[.!])\s+/)); - return uniqueNonEmpty( - segments.map((segment) => segment.replace(/^[-*]\s*/, "").trim()), - 8, - ); - }; - - const MEMORY_CATEGORY_LABELS: Record = { - preference: "Preference", - convention: "Convention", - decision: "Decision", - gotcha: "Gotcha", - fact: "Fact", - }; - - const formatAutoCapturedMemoryContent = ( - category: AutoCapturedMemoryCandidate["category"], - clause: string, - ): string => { - const prefix = MEMORY_CATEGORY_LABELS[category]; - const cleaned = clause - .replace(/^(?:please\s+)?remember(?:\s+this|\s+that)?[:,]?\s*/i, "") - .replace(/^keep in mind[:,]?\s*/i, "") - .replace(/^note that[:,]?\s*/i, "") - .replace(/^that\s+/i, "") - .replace(new RegExp(`^${prefix}:\\s*`, "i"), "") - .trim() - .replace(/[.;:\s]+$/, ""); - const body = /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`; - return `${prefix}: ${body}`; - }; - - /** Reject content that looks like it contains secrets or PII. */ - const AUTO_MEMORY_SECRET_PII_RE = new RegExp( - [ - // API keys / tokens (generic key-like hex/base64 strings after common prefixes) - /(?:api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|bearer)\s*[:=]\s*\S{8,}/i.source, - // Passwords / secrets in assignment form - /(?:password|passwd|pwd|secret)\s*[:=]\s*\S{4,}/i.source, - // AWS-style keys - /\bAKIA[0-9A-Z]{16}\b/.source, - // GitHub / GitLab personal access tokens - /\b(?:ghp|gho|ghu|ghs|ghr|glpat)[_-][A-Za-z0-9]{16,}\b/.source, - // Slack tokens - /\bxox[bpras]-[A-Za-z0-9\-]{10,}\b/.source, - // Email addresses (PII) - /\b[A-Za-z0-9._%+\-]{2,}@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/.source, - // US Social Security Numbers - /\b\d{3}[- ]?\d{2}[- ]?\d{4}\b/.source, - // Credit card numbers (13-19 digits, optionally separated) - /\b(?:\d[ -]?){13,19}\b/.source, - // Private keys / certificates - /-----BEGIN\s+(?:RSA\s+)?(?:PRIVATE\s+KEY|CERTIFICATE)/.source, - ].join("|"), - ); - - const extractAutoCapturedMemoryCandidate = (promptText: string): AutoCapturedMemoryCandidate | null => { - const trimmed = promptText.trim(); - if (trimmed.length < 16 || trimmed.length > 500) return null; - if (trimmed.startsWith("/")) return null; - if (AUTO_MEMORY_META_RE.test(trimmed) || AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return null; - if (AUTO_MEMORY_SECRET_PII_RE.test(trimmed)) return null; - - for (const clause of splitAutoMemoryCaptureClauses(trimmed)) { - const normalized = clause.replace(/\s+/g, " ").trim(); - if (normalized.length < 12 || normalized.length > 220) continue; - if (normalized.endsWith("?")) continue; - - const hasCodeHint = AUTO_MEMORY_CODE_TARGET_RE.test(normalized) - || AUTO_MEMORY_TOOLCHAIN_RE.test(normalized) - || /\b(?:npm|pnpm|yarn|bun|eslint|prettier|vitest|jest|playwright|typescript|tsc)\b/i.test(normalized) - || AUTO_MEMORY_FILE_PATH_RE.test(normalized); - const explicitSave = AUTO_MEMORY_EXPLICIT_SAVE_RE.test(normalized); - - if (AUTO_MEMORY_PREFERENCE_SAVE_RE.test(normalized)) { - return { - category: "preference", - content: formatAutoCapturedMemoryContent("preference", normalized), - importance: "medium", - writeMode: "strict", - reason: "explicit user preference", - }; - } - - if (AUTO_MEMORY_DECISION_SAVE_RE.test(normalized)) { - return { - category: "decision", - content: formatAutoCapturedMemoryContent("decision", normalized), - importance: "high", - writeMode: "strict", - reason: "explicit project decision", - }; - } - - if (AUTO_MEMORY_CONVENTION_SAVE_RE.test(normalized) && hasCodeHint) { - return { - category: "convention", - content: formatAutoCapturedMemoryContent("convention", normalized), - importance: "high", - writeMode: "strict", - reason: "explicit project convention", - }; - } - - if (AUTO_MEMORY_GOTCHA_SAVE_RE.test(normalized) && hasCodeHint) { - return { - category: "gotcha", - content: formatAutoCapturedMemoryContent("gotcha", normalized), - importance: "high", - writeMode: explicitSave ? "strict" : "default", - reason: "explicit failure mode or pitfall", - }; - } - - if (explicitSave) { - const category: AutoCapturedMemoryCandidate["category"] = hasCodeHint ? "convention" : "fact"; - return { - category, - content: formatAutoCapturedMemoryContent(category, normalized), - importance: hasCodeHint ? "high" : "medium", - writeMode: "strict", - reason: hasCodeHint ? "explicit remembered convention" : "explicit remembered fact", - }; - } - } - - return null; - }; - - const buildAutoCapturedMemoryNotice = ( - candidate: AutoCapturedMemoryCandidate, - result: WriteMemoryResult, - ): { message: string; detail?: string } => { - if (!result.accepted || !result.memory) { - return { - message: `Skipped auto-memory capture: ${result.reason ?? "write rejected"}`, - detail: `Candidate: ${candidate.content}`, - }; - } - - const memory = result.memory; - return { - message: result.deduped - ? "Merged explicit user instruction into memory" - : "Captured explicit user instruction into memory", - detail: [ - `Category: ${memory.category}`, - `Durability: ${memory.status}`, - `Tier: ${memory.tier}`, - `Reason: ${candidate.reason}`, - `Content: ${candidate.content}`, - ].join("\n"), - }; - }; - - const maybeAutoCaptureTurnMemory = ( - managed: ManagedChatSession, - promptText: string, - turnId?: string, - ): void => { - if (!memoryService || !projectId || isLightweightSession(managed.session)) return; - const candidate = extractAutoCapturedMemoryCandidate(promptText); - if (!candidate) return; - - const writePolicy = resolveAgentMemoryWritePolicy({ writeGateMode: candidate.writeMode }); - let result: WriteMemoryResult; - try { - result = memoryService.writeMemory({ - projectId, - scope: "project", - category: candidate.category, - content: candidate.content, - importance: candidate.importance, - status: writePolicy.status, - tier: writePolicy.tier, - confidence: writePolicy.confidence, - sourceSessionId: managed.session.id, - sourceType: "user", - sourceId: "chat:auto-capture", - agentId: managed.session.identityKey ?? managed.session.id, - writeGateMode: candidate.writeMode, - }); - } catch (err) { - logger.warn("agent_chat.auto_memory_capture_failed", { - sessionId: managed.session.id, - error: err instanceof Error ? err.message : String(err), - }); - return; - } - - const notice = buildAutoCapturedMemoryNotice(candidate, result); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "memory", - message: notice.message, - ...(notice.detail ? { detail: notice.detail } : {}), - ...(turnId ? { turnId } : {}), - }); - }; - const buildAutoMemoryTurnPlan = async ( managed: ManagedChatSession, promptText: string, attachments: AgentChatFileRef[] = [], ): Promise => { const classification = classifyAutoMemoryTurn(promptText, attachments.length); - const shouldLoadBootstrap = shouldLoadAutoMemoryBootstrap(promptText, attachments.length); - - const fileContext = (() => { - if (!memoryFilesService || !shouldLoadBootstrap) { - return { - text: "", - bootstrapLoaded: false, - topicFilesLoaded: [], - }; - } - try { - return memoryFilesService.buildPromptContext({ - promptText, - maxBootstrapLines: classification === "required" ? 80 : 60, - maxTopicFiles: classification === "required" ? 2 : 1, - maxTopicLines: classification === "required" ? 18 : 12, - maxChars: classification === "required" ? 2_400 : 1_600, - }); - } catch { - return { - text: "", - bootstrapLoaded: false, - topicFilesLoaded: [], - }; - } - })(); - if (!memoryService || !projectId) { - return { - classification: "none", - contextText: fileContext.text, - telemetry: { - ...EMPTY_MEMORY_TELEMETRY, - bootstrapLoaded: fileContext.bootstrapLoaded, - topicFilesLoaded: fileContext.topicFilesLoaded, - }, - selectedEntries: [], - }; + return { classification: "none", contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY }; } - if (isLightweightSession(managed.session) || (classification === "none" && !shouldLoadBootstrap)) { - return { classification, contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY, selectedEntries: [] }; - } - - if (classification === "none") { - return { - classification, - contextText: fileContext.text, - telemetry: { - searched: false, - projectHits: 0, - agentHits: 0, - totalHits: 0, - injectedCount: 0, - includedProcedure: false, - bootstrapLoaded: fileContext.bootstrapLoaded, - topicFilesLoaded: fileContext.topicFilesLoaded, - }, - selectedEntries: [], - }; + if (isLightweightSession(managed.session) || classification === "none") { + return { classification, contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY }; } const query = promptText.trim().slice(0, 300); @@ -1873,18 +1809,14 @@ export function createAgentChatService(args: { }).catch(() => []), ]); - const allQualifying = selectAutoMemoryEntries([...projectHits, ...agentHits], promptText, 32); + const allQualifying = selectAutoMemoryEntries([...projectHits, ...agentHits], 32); const selected = allQualifying.slice(0, 4); - const contextSections = [ - fileContext.text.length > 0 ? fileContext.text : null, - selected.length > 0 - ? [ + const contextText = selected.length === 0 + ? "" + : [ "Relevant ADE memory for this turn (use it when helpful; current code and files win if they disagree):", ...selected.map((memory) => `- [${memory.scope}/${memory.category}] ${compactMemorySnippet(memory.content, 180)}`), - ].join("\n") - : null, - ].filter((section): section is string => Boolean(section)); - const contextText = contextSections.join("\n\n"); + ].join("\n"); return { classification, @@ -1896,16 +1828,7 @@ export function createAgentChatService(args: { totalHits: allQualifying.length, injectedCount: selected.length, includedProcedure: selected.some((memory) => memory.category === "procedure"), - bootstrapLoaded: fileContext.bootstrapLoaded, - topicFilesLoaded: fileContext.topicFilesLoaded, }, - selectedEntries: selected.map((memory) => ({ - scope: memory.scope === "agent" ? "agent" : "project", - category: memory.category, - snippet: compactMemorySnippet(memory.content, 180), - pinned: Boolean(memory.pinned), - tier: typeof memory.tier === "number" ? memory.tier : null, - })), }; }; @@ -1936,7 +1859,77 @@ export function createAgentChatService(args: { const buildClaudeCanUseTool = ( runtime: ClaudeRuntime, + managed: ManagedChatSession, ): ClaudeSDKOptions["canUseTool"] => async (toolName, input): Promise => { + // ── ExitPlanMode interception ── + // 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") { + const inputRecord = (input && typeof input === "object" && !Array.isArray(input)) ? input as Record : {}; + const planContent = typeof inputRecord.planDescription === "string" + ? inputRecord.planDescription + : typeof inputRecord.plan === "string" + ? inputRecord.plan + : ""; + const planSummary = planContent.length > 0 + ? planContent + : "The agent has prepared a plan. Review and approve to proceed with implementation."; + + const approvalItemId = randomUUID(); + const turnId = runtime.activeTurnId ?? undefined; + const request: PendingInputRequest = { + requestId: approvalItemId, + itemId: approvalItemId, + source: "claude", + kind: "plan_approval", + title: "Plan Ready for Review", + description: planSummary, + questions: [{ + id: "plan_decision", + header: "Implementation Plan", + question: planSummary, + options: [ + { label: "Approve & Implement", value: "approve", recommended: true }, + { label: "Reject & Revise", value: "reject" }, + ], + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { tool: "ExitPlanMode", planContent }, + turnId: turnId ?? null, + }; + + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: "Plan ready for approval", + detail: { tool: "ExitPlanMode", planContent }, + }); + + // Block until the user responds via the approval UI. + const response = await new Promise<{ decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }>((resolve) => { + runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); + }); + runtime.approvals.delete(approvalItemId); + + const approved = response.decision === "accept" || response.decision === "accept_for_session"; + if (approved) { + // Allow the tool — the SDK will process ExitPlanMode normally and + // Claude will receive the standard "plan approved" tool result. + return { behavior: "allow" }; + } + + // Denied — tell Claude the user rejected the plan. + const feedback = typeof response.responseText === "string" ? response.responseText.trim() : ""; + return { + behavior: "deny", + message: feedback.length > 0 + ? `The user rejected your plan with feedback: "${feedback}". Please revise and try again.` + : "The user rejected your plan. Please revise your approach and try again.", + }; + } + const state = runtime.turnMemoryPolicyState; if (isMemorySearchToolName(toolName) && state) { state.explicitSearchPerformed = true; @@ -2297,11 +2290,95 @@ export function createAgentChatService(args: { return combined.slice(-limit).join("\n"); }; + const usesIdentityContinuity = (managed: ManagedChatSession): boolean => Boolean(managed.session.identityKey); + + const buildDeterministicContinuitySummary = (managed: ManagedChatSession): string | null => { + const recentConversation = buildRecentConversationContext(managed, 8).trim(); + if (!recentConversation.length) return null; + return [ + "Recent continuity snapshot:", + recentConversation, + ].join("\n"); + }; + + const maybeRefreshIdentityContinuitySummary = async ( + managed: ManagedChatSession, + reason: "compaction" | "provider_reset", + ): Promise => { + if (!usesIdentityContinuity(managed)) return; + if (managed.continuitySummaryInFlight) return; + + const deterministic = buildDeterministicContinuitySummary(managed); + if (!deterministic) return; + + managed.continuitySummary = deterministic; + managed.continuitySummaryUpdatedAt = nowIso(); + persistChatState(managed); + + const auth = await detectAuth().catch(() => []); + const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); + if (!availableModels.length) return; + + const preferredModelId = + [ + resolveChatConfig().summaryModelId, + DEFAULT_AUTO_TITLE_MODEL_ID, + "anthropic/claude-haiku-4-5", + "openai/gpt-5.4-mini", + "openai/gpt-5.2", + availableModels[0]?.id, + ].find((candidate) => { + const modelId = typeof candidate === "string" ? candidate.trim() : ""; + return modelId.length > 0 && availableModels.some((descriptor) => descriptor.id === modelId); + }) ?? null; + + if (!preferredModelId) return; + const descriptor = getModelById(preferredModelId); + if (!descriptor) return; + + const prompt = [ + "You are ADE's continuity compaction assistant.", + "Summarize the persistent identity chat's active continuity for recovery after provider resets or context compaction.", + "Focus on current objectives, active delegations, decisions already made, and blockers that still matter.", + "Return 3-6 concise bullet points and do not add Markdown headings.", + "", + `Reason: ${reason}`, + `Identity: ${managed.session.identityKey}`, + deterministic, + ].join("\n"); + + managed.continuitySummaryInFlight = true; + try { + const resolvedModel = await providerResolver.resolveModel(descriptor.id, auth, { + cwd: managed.laneWorktreePath, + middleware: false, + }); + const result = await generateText({ + model: resolvedModel, + prompt, + }); + const text = result.text.trim(); + if (text.length) { + managed.continuitySummary = text; + managed.continuitySummaryUpdatedAt = nowIso(); + persistChatState(managed); + } + } catch (error) { + logger.warn("agent_chat.identity_continuity_summary_failed", { + sessionId: managed.session.id, + reason, + modelId: descriptor.id, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + managed.continuitySummaryInFlight = false; + } + }; + const appendRecentConversationEntry = (managed: ManagedChatSession, event: AgentChatEvent): void => { if (event.type !== "user_message" && event.type !== "text") return; const text = event.text.trim(); if (!text.length) return; - if (event.type === "user_message" && event.deliveryState === "queued") return; const role = event.type === "user_message" ? "user" : "assistant"; const turnId = "turnId" in event ? event.turnId : undefined; @@ -2336,6 +2413,13 @@ export function createAgentChatService(args: { } } + if (usesIdentityContinuity(managed) && managed.continuitySummary?.trim()) { + sections.push([ + "Continuity Summary", + managed.continuitySummary.trim(), + ].join("\n")); + } + if (options?.includeConversationTail) { const recentConversation = buildRecentConversationContext(managed); if (recentConversation.length) { @@ -2737,6 +2821,7 @@ export function createAgentChatService(args: { managed.runtime = runtime; managed.session.provider = "unified"; managed.session.unifiedPermissionMode = permMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.session.capabilityMode = "fallback"; return "handled"; }; @@ -2825,11 +2910,21 @@ export function createAgentChatService(args: { return sha.length ? sha : null; }; + const resolvePrimaryIdentityLane = async (): Promise => { + await laneService.ensurePrimaryLane?.().catch(() => {}); + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null; + if (!primary?.id) { + throw new Error("No lane is available to host the canonical identity chat session."); + } + return primary.id; + }; + const metadataPathFor = (sessionId: string): string => path.join(chatSessionsDir, `${sessionId}.json`); const persistChatState = (managed: ManagedChatSession): void => { const payload: PersistedChatState = { - version: 1, + version: 2, sessionId: managed.session.id, laneId: managed.session.laneId, provider: managed.session.provider, @@ -2838,11 +2933,13 @@ export function createAgentChatService(args: { ...(managed.session.sessionProfile ? { sessionProfile: managed.session.sessionProfile } : {}), ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), ...(managed.session.executionMode ? { executionMode: managed.session.executionMode } : {}), + ...(managed.session.interactionMode ? { interactionMode: managed.session.interactionMode } : {}), ...(managed.session.claudePermissionMode ? { claudePermissionMode: managed.session.claudePermissionMode } : {}), ...(managed.session.codexApprovalPolicy ? { codexApprovalPolicy: managed.session.codexApprovalPolicy } : {}), ...(managed.session.codexSandbox ? { codexSandbox: managed.session.codexSandbox } : {}), ...(managed.session.codexConfigSource ? { codexConfigSource: managed.session.codexConfigSource } : {}), ...(managed.session.unifiedPermissionMode ? { unifiedPermissionMode: managed.session.unifiedPermissionMode } : {}), + ...(managed.session.permissionMode ? { permissionMode: managed.session.permissionMode } : {}), ...(managed.session.identityKey ? { identityKey: managed.session.identityKey } : {}), ...(managed.session.surface ? { surface: managed.session.surface } : {}), ...(managed.session.automationId ? { automationId: managed.session.automationId } : {}), @@ -2855,6 +2952,19 @@ export function createAgentChatService(args: { ...(managed.runtime?.kind === "unified" ? { messages: managed.runtime.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content })) } : {}), + ...(managed.recentConversationEntries.length + ? { + recentConversationEntries: managed.recentConversationEntries.map((entry) => ({ + role: entry.role, + text: entry.text, + ...(entry.turnId ? { turnId: entry.turnId } : {}), + })), + } + : {}), + ...(managed.continuitySummary ? { continuitySummary: managed.continuitySummary } : {}), + ...(managed.continuitySummaryUpdatedAt ? { continuitySummaryUpdatedAt: managed.continuitySummaryUpdatedAt } : {}), + ...(managed.preferredExecutionLaneId ? { preferredExecutionLaneId: managed.preferredExecutionLaneId } : {}), + ...(managed.selectedExecutionLaneId ? { selectedExecutionLaneId: managed.selectedExecutionLaneId } : {}), updatedAt: nowIso() }; @@ -2869,69 +2979,6 @@ export function createAgentChatService(args: { } }; - const resolveCodexThreadWaiters = (runtime: CodexRuntime, threadId?: string): void => { - if (runtime.threadIdWaiters.size === 0) return; - for (const waiter of runtime.threadIdWaiters) { - try { - waiter(threadId); - } catch { - // ignore waiter errors - } - } - runtime.threadIdWaiters.clear(); - }; - - const setCodexThreadIdentity = ( - managed: ManagedChatSession, - runtime: CodexRuntime, - threadId: string | null | undefined, - ): string | null => { - const normalized = String(threadId ?? "").trim(); - if (!normalized.length) return null; - const changed = managed.session.threadId !== normalized; - managed.session.threadId = normalized; - sessionService.setResumeCommand(managed.session.id, `chat:codex:${normalized}`); - resolveCodexThreadWaiters(runtime, normalized); - if (changed) { - persistChatState(managed); - } - return normalized; - }; - - const waitForCodexThreadIdentity = async ( - runtime: CodexRuntime, - timeoutMs = 1200, - ): Promise => { - return new Promise((resolve) => { - let settled = false; - const timeout = setTimeout(() => { - if (settled) return; - settled = true; - runtime.threadIdWaiters.delete(waiter); - resolve(undefined); - }, timeoutMs); - const waiter = (threadId?: string) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - runtime.threadIdWaiters.delete(waiter); - resolve(threadId); - }; - runtime.threadIdWaiters.add(waiter); - }); - }; - - const maybeDrainQueuedSteer = async ( - managed: ManagedChatSession, - queue: string[], - runner: (text: string) => Promise, - ): Promise => { - if (managed.closed) return; - const steerText = shiftPendingSteer(queue); - if (!steerText) return; - await runner(steerText); - }; - const readPersistedState = (sessionId: string): PersistedChatState | null => { const filePath = metadataPathFor(sessionId); if (!fs.existsSync(filePath)) return null; @@ -2939,7 +2986,7 @@ export function createAgentChatService(args: { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; if (!parsed || typeof parsed !== "object") return null; const record = parsed as Partial; - if (record.version !== 1) return null; + if (record.version !== 1 && record.version !== 2) return null; const provider = record.provider; if (provider !== "codex" && provider !== "claude" && provider !== "unified") return null; const laneId = String(record.laneId ?? "").trim(); @@ -2950,7 +2997,10 @@ export function createAgentChatService(args: { const sessionProfile = normalizeSessionProfile(record.sessionProfile); const reasoningEffort = normalizeReasoningEffort(record.reasoningEffort); const executionMode = normalizePersistedExecutionMode(record.executionMode); + const permissionMode = normalizePersistedPermissionMode(record.permissionMode); const claudePermissionMode = normalizePersistedClaudePermissionMode(record.claudePermissionMode); + const interactionMode = normalizePersistedInteractionMode(record.interactionMode) + ?? (provider === "claude" && (claudePermissionMode === "plan" || permissionMode === "plan") ? "plan" : undefined); const codexApprovalPolicy = normalizePersistedCodexApprovalPolicy(record.codexApprovalPolicy); const codexSandbox = normalizePersistedCodexSandbox(record.codexSandbox); const codexConfigSource = normalizePersistedCodexConfigSource(record.codexConfigSource); @@ -2970,9 +3020,19 @@ export function createAgentChatService(args: { return (role === "user" || role === "assistant") && typeof content === "string"; }) : undefined; + const recentConversationEntries = Array.isArray(record.recentConversationEntries) + ? record.recentConversationEntries + .filter((entry): entry is PersistedRecentConversationEntry => { + if (!entry || typeof entry !== "object") return false; + const role = (entry as { role?: unknown }).role; + const text = (entry as { text?: unknown }).text; + return (role === "user" || role === "assistant") && typeof text === "string" && text.trim().length > 0; + }) + .slice(-12) + : undefined; const sdkSessionId = typeof record.sdkSessionId === "string" && record.sdkSessionId.trim().length ? record.sdkSessionId.trim() : undefined; const hydrated: PersistedChatState = { - version: 1, + version: 2, sessionId, laneId, provider, @@ -2981,11 +3041,13 @@ export function createAgentChatService(args: { ...(sessionProfile ? { sessionProfile } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(executionMode ? { executionMode } : {}), + ...(interactionMode ? { interactionMode } : {}), ...(claudePermissionMode ? { claudePermissionMode } : {}), ...(codexApprovalPolicy ? { codexApprovalPolicy } : {}), ...(codexSandbox ? { codexSandbox } : {}), ...(codexConfigSource ? { codexConfigSource } : {}), ...(unifiedPermissionMode ? { unifiedPermissionMode } : {}), + ...(permissionMode ? { permissionMode } : {}), ...(identityKey ? { identityKey } : {}), surface, ...(typeof record.automationId === "string" && record.automationId.trim().length @@ -3002,8 +3064,22 @@ export function createAgentChatService(args: { : {}), ...(sdkSessionId ? { sdkSessionId } : {}), ...(messages?.length ? { messages } : {}), + ...(recentConversationEntries?.length ? { recentConversationEntries } : {}), + ...(typeof record.continuitySummary === "string" && record.continuitySummary.trim().length + ? { continuitySummary: record.continuitySummary.trim() } + : {}), + ...(typeof record.continuitySummaryUpdatedAt === "string" && record.continuitySummaryUpdatedAt.trim().length + ? { continuitySummaryUpdatedAt: record.continuitySummaryUpdatedAt.trim() } + : {}), + ...(typeof record.preferredExecutionLaneId === "string" && record.preferredExecutionLaneId.trim().length + ? { preferredExecutionLaneId: record.preferredExecutionLaneId.trim() } + : {}), + ...(typeof record.selectedExecutionLaneId === "string" && record.selectedExecutionLaneId.trim().length + ? { selectedExecutionLaneId: record.selectedExecutionLaneId.trim() } + : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; + hydrateNativePermissionControls(hydrated as Parameters[0]); return hydrated; } catch { return null; @@ -3115,8 +3191,7 @@ export function createAgentChatService(args: { ): void => { const buffered = managed.previewTextBuffer; const sameChunk = buffered - && (buffered.turnId ?? null) === (event.turnId ?? null) - && (buffered.itemId ?? null) === (event.itemId ?? null); + && canAppendBufferedAssistantText(buffered, event); if (sameChunk) { buffered.text += event.text; @@ -3126,6 +3201,7 @@ export function createAgentChatService(args: { managed.previewTextBuffer = { text: event.text, + ...(event.messageId ? { messageId: event.messageId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: event.itemId } : {}), }; @@ -3155,11 +3231,17 @@ export function createAgentChatService(args: { } if (event.type === "done") { - const preview = managed.preview?.trim() ?? ""; - const summary = preview.length - ? (event.status === "completed" ? preview : `${event.status}: ${preview}`) - : (event.status === "completed" ? "Response ready" : `Turn ${event.status}`); - sessionService.setSummary(managed.session.id, summary); + // Only set a fallback summary if no completion_report already provided one. + const hasCompletionSummary = managed.session.completion?.summary?.trim().length; + if (!hasCompletionSummary) { + const preview = managed.preview?.trim() ?? ""; + const summary = preview.length + ? (event.status === "completed" ? preview : `${event.status}: ${preview}`) + : (event.status === "completed" ? "Response ready" : `Turn ${event.status}`); + sessionService.setSummary(managed.session.id, summary); + } + // Fire AI-enhanced summary after each completed turn (not just on session end). + void maybeGenerateSessionSummary(managed, null); } const envelope: AgentChatEventEnvelope = { @@ -3224,6 +3306,7 @@ export function createAgentChatService(args: { commitChatEvent(managed, { type: "text", text: buffered.text, + ...(buffered.messageId ? { messageId: buffered.messageId } : {}), ...(buffered.turnId ? { turnId: buffered.turnId } : {}), ...(buffered.itemId ? { itemId: buffered.itemId } : {}), }); @@ -3285,46 +3368,63 @@ export function createAgentChatService(args: { }; const emitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { - if (event.type === "text") { - queueBufferedTextEvent(managed, event); + const normalizedEvent = (() => { + switch (event.type) { + case "text": + return ensureAssistantMessageId(managed, event); + case "tool_call": + case "tool_result": + case "command": + case "file_change": + case "approval_request": + case "web_search": + return ensureLogicalItemId(event); + default: + return event; + } + })(); + + if (normalizedEvent.type === "text") { + queueBufferedTextEvent(managed, normalizedEvent); return; } - if (event.type === "reasoning") { - queueReasoningEvent(managed, event); + if (normalizedEvent.type === "reasoning") { + queueReasoningEvent(managed, normalizedEvent); return; } - if (event.type === "activity") { - const signature = `${event.turnId ?? ""}:${event.activity}:${event.detail ?? ""}`; + if (normalizedEvent.type === "activity") { + const signature = `${normalizedEvent.turnId ?? ""}:${normalizedEvent.activity}:${normalizedEvent.detail ?? ""}`; if (signature === managed.lastActivitySignature) { return; } flushBufferedReasoning(managed); - if (shouldFlushBufferedAssistantTextForEvent(event)) { + if (shouldFlushBufferedAssistantTextForEvent(normalizedEvent)) { flushBufferedText(managed); } managed.lastActivitySignature = signature; - commitChatEvent(managed, event); + commitChatEvent(managed, normalizedEvent); return; } flushBufferedReasoning(managed); - if (shouldFlushBufferedAssistantTextForEvent(event)) { + if (shouldFlushBufferedAssistantTextForEvent(normalizedEvent)) { flushBufferedText(managed); + resetAssistantMessageStream(managed); } if ( - event.type === "user_message" - || event.type === "status" - || event.type === "done" - || event.type === "step_boundary" - || event.type === "error" + normalizedEvent.type === "user_message" + || normalizedEvent.type === "status" + || normalizedEvent.type === "done" + || normalizedEvent.type === "step_boundary" + || normalizedEvent.type === "error" ) { managed.lastActivitySignature = null; } - commitChatEvent(managed, event); + commitChatEvent(managed, normalizedEvent); }; const emitPendingInputRequest = ( @@ -3389,6 +3489,128 @@ export function createAgentChatService(args: { return normalized; }; + const requestExecutionLaneForIdentitySession = async ( + managed: ManagedChatSession, + args: { + requestedLaneId?: string | null; + purpose: string; + freshLaneName?: string | null; + freshLaneDescription?: string | null; + }, + ): Promise => { + const explicitLaneId = typeof args.requestedLaneId === "string" ? args.requestedLaneId.trim() : ""; + if (!usesIdentityContinuity(managed) || managed.session.surface === "automation") { + return explicitLaneId || managed.preferredExecutionLaneId || managed.selectedExecutionLaneId || managed.session.laneId; + } + if (managed.preferredExecutionLaneId) { + return managed.preferredExecutionLaneId; + } + + const primaryLaneId = await resolvePrimaryIdentityLane(); + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + const selectedLaneId = explicitLaneId || managed.selectedExecutionLaneId || primaryLaneId; + const primaryLane = lanes.find((lane) => lane.id === primaryLaneId) ?? null; + const selectedLane = lanes.find((lane) => lane.id === selectedLaneId) ?? null; + const itemId = randomUUID(); + const request: PendingInputRequest = { + requestId: itemId, + itemId, + source: "ade", + kind: "structured_question", + title: "Choose execution lane", + description: `Choose where ADE should launch implementation work for ${args.purpose}.`, + questions: [{ + id: "lane_choice", + header: "Execution lane", + question: "Where should ADE launch the implementation work?", + options: [ + { + label: "Primary", + value: "primary", + description: primaryLane + ? `Keep work on the canonical primary lane (${primaryLane.name}).` + : "Keep work on the canonical primary lane.", + recommended: true, + }, + { + label: "Selected", + value: "selected", + description: selectedLane && selectedLane.id !== primaryLaneId + ? `Use the lane currently selected in the UI (${selectedLane.name}).` + : "Use the lane currently selected in the UI. If none is selected, ADE will fall back to primary.", + }, + { + label: "Fresh lane", + value: "fresh_lane", + description: "Create a dedicated implementation lane for this task before launching work.", + }, + ], + allowsFreeform: false, + }], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + promptKind: "execution_lane_choice", + purpose: args.purpose, + selectedLaneId: selectedLaneId || null, + primaryLaneId, + }, + }; + + const response = await new Promise<{ + decision?: AgentChatApprovalDecision; + answers?: Record; + responseText?: string | null; + }>((resolve) => { + managed.localPendingInputs.set(itemId, { request, resolve }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? "Choose where to launch implementation work.", + detail: request.providerMetadata as Record, + }); + persistChatState(managed); + }); + + const normalizedAnswers = normalizePendingInputAnswers(request, response.answers, response.responseText); + const selection = normalizedAnswers.lane_choice?.[0] ?? ""; + if (response.decision === "cancel" || response.decision === "decline" || !selection.length) { + emitChatEvent(managed, { + type: "tool_result", + tool: "choose_execution_lane", + result: { success: false, reason: "cancelled" }, + itemId, + status: "failed", + }); + throw new Error("Execution lane selection is required before launching implementation work."); + } + + let resolvedLaneId = primaryLaneId; + if (selection === "selected") { + resolvedLaneId = selectedLaneId || primaryLaneId; + } else if (selection === "fresh_lane") { + const createdLane = await laneService.create({ + name: (args.freshLaneName?.trim() || args.purpose).slice(0, 72), + description: args.freshLaneDescription?.trim() + || `Implementation lane launched from ${managed.session.identityKey === "cto" ? "CTO" : "employee"} chat.`, + parentLaneId: primaryLaneId, + }); + resolvedLaneId = createdLane.id; + } + + managed.preferredExecutionLaneId = resolvedLaneId; + managed.selectedExecutionLaneId = selectedLaneId || managed.selectedExecutionLaneId; + emitChatEvent(managed, { + type: "tool_result", + tool: "choose_execution_lane", + result: { success: true, selection, laneId: resolvedLaneId }, + itemId, + status: "completed", + }); + persistChatState(managed); + return resolvedLaneId; + }; + /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ const teardownRuntime = (managed: ManagedChatSession): void => { flushBufferedReasoning(managed); @@ -3432,6 +3654,7 @@ export function createAgentChatService(args: { ): Promise => { const config = resolveChatConfig(); if (!config.summaryEnabled) return; + if (managed.summaryInFlight) return; // Set the deterministic summary first (always available immediately) const session = sessionService.get(managed.session.id); @@ -3465,6 +3688,7 @@ export function createAgentChatService(args: { if (!descriptor) return; const baseSummary = session.summary ?? deterministicText ?? ""; + const userRequest = managed.autoTitleSeed?.trim() ?? ""; const prompt = [ "You are ADE's session summary assistant.", "Rewrite this chat session into a concise 1-3 sentence summary describing what was accomplished and any outcome.", @@ -3472,10 +3696,12 @@ export function createAgentChatService(args: { "", `Session title: ${session.title}`, session.goal ? `Goal: ${session.goal}` : null, + userRequest ? `User request: ${userRequest}` : null, baseSummary ? `Current summary: ${baseSummary}` : null, session.lastOutputPreview ? `Latest output: ${session.lastOutputPreview}` : null, ].filter(Boolean).join("\n"); + managed.summaryInFlight = true; try { const resolvedModel = await providerResolver.resolveModel(descriptor.id, auth, { cwd: managed.laneWorktreePath, @@ -3495,6 +3721,8 @@ export function createAgentChatService(args: { modelId: descriptor.id, error: error instanceof Error ? error.message : String(error), }); + } finally { + managed.summaryInFlight = false; } }; @@ -3508,6 +3736,10 @@ export function createAgentChatService(args: { clearSubagentSnapshots(managed.session.id); flushBufferedText(managed); flushBufferedReasoning(managed); + for (const pending of managed.localPendingInputs.values()) { + pending.resolve({ decision: "cancel" }); + } + managed.localPendingInputs.clear(); if (options?.summary !== undefined) { sessionService.setSummary(managed.session.id, options.summary); @@ -3619,10 +3851,8 @@ export function createAgentChatService(args: { const fallbackModel = persisted?.model ?? fallbackModelForProvider(provider); const hydratedModelId = persisted?.modelId ?? resolveModelIdFromStoredValue(fallbackModel, provider) - ?? fallbackModelIdForProvider(provider); - // When persisted modelId is missing we resolved through fallback — use the - // hydrated id as the CLI model string so stale metadata doesn't propagate. - const model = !persisted?.modelId || provider === "unified" ? hydratedModelId : fallbackModel; + ?? (provider === "unified" ? DEFAULT_UNIFIED_MODEL_ID : undefined); + const model = provider === "unified" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); const managed: ManagedChatSession = { @@ -3631,15 +3861,17 @@ export function createAgentChatService(args: { laneId: row.laneId, provider, model, - modelId: hydratedModelId, + ...(hydratedModelId ? { modelId: hydratedModelId } : {}), ...(persisted?.sessionProfile ? { sessionProfile: persisted.sessionProfile } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, executionMode: persisted?.executionMode ?? null, + interactionMode: persisted?.interactionMode ?? null, ...(persisted?.claudePermissionMode ? { claudePermissionMode: persisted.claudePermissionMode } : {}), ...(persisted?.codexApprovalPolicy ? { codexApprovalPolicy: persisted.codexApprovalPolicy } : {}), ...(persisted?.codexSandbox ? { codexSandbox: persisted.codexSandbox } : {}), ...(persisted?.codexConfigSource ? { codexConfigSource: persisted.codexConfigSource } : {}), ...(persisted?.unifiedPermissionMode ? { unifiedPermissionMode: persisted.unifiedPermissionMode } : {}), + ...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}), ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), capabilityMode: persisted?.capabilityMode ?? inferCapabilityMode(provider), computerUse: normalizePersistedComputerUse(persisted?.computerUse), @@ -3663,82 +3895,84 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: hasCustomChatSessionTitle(row.title, provider) ? "initial" : "none", autoTitleInFlight: false, + summaryInFlight: false, + continuitySummary: persisted?.continuitySummary ?? null, + continuitySummaryUpdatedAt: persisted?.continuitySummaryUpdatedAt ?? null, + continuitySummaryInFlight: false, + preferredExecutionLaneId: persisted?.preferredExecutionLaneId ?? null, + selectedExecutionLaneId: persisted?.selectedExecutionLaneId ?? null, + activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, previewTextBuffer: null, bufferedText: null, - recentConversationEntries: [], + recentConversationEntries: persisted?.recentConversationEntries?.map((entry) => ({ + role: entry.role, + text: entry.text, + ...(entry.turnId ? { turnId: entry.turnId } : {}), + })) ?? [], + localPendingInputs: new Map(), eventSequence: 0, - recoveryState: createRecoveryState(), }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; - refreshReconstructionContext(managed); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); managedSessions.set(sessionId, managed); return managed; }; + const emitPreparedUserMessage = ( + managed: ManagedChatSession, + args: { + text: string; + attachments: AgentChatFileRef[]; + turnId?: string; + onDispatched?: () => void; + }, + ): void => { + emitChatEvent(managed, { + type: "user_message", + text: args.text, + attachments: args.attachments, + ...(args.turnId ? { turnId: args.turnId } : {}), + }); + args.onDispatched?.(); + }; + const sendCodexMessage = async ( managed: ManagedChatSession, args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + onDispatched?: () => void; }, ): Promise => { + if (!managed.session.threadId) { + throw new Error(`Codex session '${managed.session.id}' is missing thread id.`); + } if (!managed.runtime || managed.runtime.kind !== "codex") { throw new Error(`Codex runtime is not available for session '${managed.session.id}'.`); } - const runtime = managed.runtime; - if (runtime.activeTurnId) { + if (managed.runtime.activeTurnId) { throw new Error("A turn is already active. Use steer or interrupt."); } - let threadId = managed.session.threadId ?? null; - if (!threadId) { - threadId = (await waitForCodexThreadIdentity(runtime)) ?? null; - if (threadId) { - setCodexThreadIdentity(managed, runtime, threadId); - } - } - if (!threadId) { - // Recovery attempt 1: check persisted state - const persisted = readPersistedState(managed.session.id); - if (persisted?.threadId) { - threadId = persisted.threadId; - setCodexThreadIdentity(managed, runtime, threadId); - } - } - if (!threadId) { - // Recovery attempt 2: rebind fresh thread - logger.warn("agent_chat.codex_thread_recovery", { - sessionId: managed.session.id, - message: "Thread identity lost; starting fresh thread for recovery.", - }); - const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); - await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); - threadId = managed.session.threadId ?? null; - } - if (!threadId) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "thread_error", - message: "This Codex chat lost its thread identity.", - detail: "ADE could not recover the current Codex thread id after all recovery attempts. Please start a new chat session.", - }); - throw new Error(`Codex session '${managed.session.id}' is missing thread id after recovery.`); - } + const runtime = managed.runtime; const attachments = args.attachments ?? []; const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - maybeAutoCaptureTurnMemory(managed, displayText); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, displayText, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); // Intercept /review command — route to review/start RPC instead of turn/start if (args.promptText.trim().startsWith("/review")) { - emitChatEvent(managed, { type: "user_message", text: displayText, attachments }); + emitPreparedUserMessage(managed, { + text: displayText, + attachments, + onDispatched: args.onDispatched, + }); const reviewResult = await runtime.request<{ turn?: { id?: string } }>("review/start", { - threadId, + threadId: managed.session.threadId, target: "uncommittedChanges", }); const reviewTurnId = typeof reviewResult.turn?.id === "string" ? reviewResult.turn.id : null; @@ -3785,7 +4019,11 @@ export function createAgentChatService(args: { } managed.session.status = "active"; - emitChatEvent(managed, { type: "user_message", text: displayText, attachments }); + emitPreparedUserMessage(managed, { + text: displayText, + attachments, + onDispatched: args.onDispatched, + }); if (autoMemoryNotice) { emitChatEvent(managed, { type: "system_notice", @@ -3796,7 +4034,7 @@ export function createAgentChatService(args: { } const result = await managed.runtime.request<{ turn?: { id?: string } }>("turn/start", { - threadId, + threadId: managed.session.threadId, input, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}) }); @@ -3907,6 +4145,7 @@ export function createAgentChatService(args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + onDispatched?: () => void; }, ): Promise => { const runtime = managed.runtime; @@ -3927,7 +4166,12 @@ export function createAgentChatService(args: { const attachments = args.attachments ?? []; const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - emitChatEvent(managed, { type: "user_message", text: displayText, attachments, turnId }); + emitPreparedUserMessage(managed, { + text: displayText, + attachments, + turnId, + onDispatched: args.onDispatched, + }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); let assistantText = ""; @@ -3960,7 +4204,6 @@ export function createAgentChatService(args: { try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - maybeAutoCaptureTurnMemory(managed, autoMemoryPrompt, turnId); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); runtime.turnMemoryPolicyState = { @@ -4027,15 +4270,21 @@ export function createAgentChatService(args: { // Build the message — plain string for text-only, or SDKUserMessage with // image content blocks (streaming input format per SDK docs). const messageToSend = buildClaudeV2Message(basePromptText, attachments); + const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); + + if (typeof runtime.v2Session.setPermissionMode === "function") { + await runtime.v2Session.setPermissionMode(turnPermissionMode); + } else if (turnPermissionMode === "plan") { + throw new Error("Claude plan mode is not available in this Claude SDK build."); + } // V2 pattern: send() then stream() per turn. Session stays alive between turns. await runtime.v2Session.send(messageToSend); - runtime.v2StreamGen = runtime.v2Session.stream(); // Don't emit a pre-emptive "thinking" activity — wait for actual content from the stream. // The renderer will show the turn as "started" (from the status event above) which is sufficient. - for await (const msg of runtime.v2StreamGen) { + for await (const msg of runtime.v2Session.stream()) { if (runtime.interrupted) break; markFirstStreamEvent(msg.type); @@ -4101,7 +4350,8 @@ export function createAgentChatService(args: { })), }); } - refreshReconstructionContext(managed); + void maybeRefreshIdentityContinuitySummary(managed, "compaction"); + refreshReconstructionContext(managed, { includeConversationTail: true }); } continue; } @@ -4485,9 +4735,7 @@ export function createAgentChatService(args: { runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; - runtime.v2StreamGen = null; runtime.turnMemoryPolicyState = null; - runtime.activeSubagents.clear(); managed.session.status = "idle"; reportProviderRuntimeReady("claude"); @@ -4527,17 +4775,17 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - await maybeDrainQueuedSteer( - managed, - runtime.pendingSteers, - async (steerText) => runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), - ); + if (!managed.closed && runtime.pendingSteers.length) { + const steerText = runtime.pendingSteers.shift() ?? ""; + if (steerText.trim().length) { + await runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); + } + } } catch (error) { runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; - runtime.activeSubagents.clear(); // Close V2 session on error so the next turn starts fresh try { runtime.v2Session?.close(); } catch { /* ignore */ } @@ -4571,15 +4819,6 @@ export function createAgentChatService(args: { message: errorMessage, turnId, }); - if (isAuthFailure || /\b(network|timed out|econn|socket)\b/i.test(errorMessage)) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "provider_health", - message: "Claude runtime issue", - detail: errorMessage, - turnId, - }); - } emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); emitChatEvent(managed, { type: "done", @@ -4601,23 +4840,14 @@ export function createAgentChatService(args: { sdkSessionId: runtime.sdkSessionId, error: error instanceof Error ? error.message : String(error), }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "thread_error", - message: "Claude session state was reset after a session error.", - detail: error instanceof Error ? error.message : String(error), - turnId, - }); runtime.sdkSessionId = null; + void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + prewarmClaudeV2Session(managed); } } persistChatState(managed); - await maybeDrainQueuedSteer( - managed, - runtime.pendingSteers, - async (steerText) => runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), - ); } }; @@ -4629,6 +4859,7 @@ export function createAgentChatService(args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + onDispatched?: () => void; }, ): Promise => { const runtimeKind = managed.runtime?.kind; @@ -4652,7 +4883,12 @@ export function createAgentChatService(args: { managed.session.status = "active"; const attachments = args.attachments ?? []; const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - emitChatEvent(managed, { type: "user_message", text: displayText, attachments, turnId }); + emitPreparedUserMessage(managed, { + text: displayText, + attachments, + turnId, + onDispatched: args.onDispatched, + }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); let assistantText = ""; @@ -4676,7 +4912,6 @@ export function createAgentChatService(args: { try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - maybeAutoCaptureTurnMemory(managed, autoMemoryPrompt, turnId); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); const turnMemoryPolicyState: TurnMemoryPolicyState | undefined = memoryService && projectId @@ -4802,15 +5037,30 @@ export function createAgentChatService(args: { }; } + const isPlanApproval = category === "exitPlanMode"; + const planContent = isPlanApproval && detail && typeof detail === "object" && !Array.isArray(detail) + ? (detail as Record).planContent as string | undefined + : undefined; + const approvalItemId = randomUUID(); const request: PendingInputRequest = { requestId: approvalItemId, itemId: approvalItemId, source: "unified", - kind: "approval", + kind: isPlanApproval ? "plan_approval" : "approval", + ...(isPlanApproval ? { title: "Plan Ready for Review" } : {}), description, - questions: [], - allowsFreeform: false, + questions: isPlanApproval ? [{ + id: "plan_decision", + header: "Implementation Plan", + question: planContent ?? description, + options: [ + { label: "Approve & Implement", value: "approve", recommended: true }, + { label: "Reject & Revise", value: "reject" }, + ], + allowsFreeform: true, + }] : [], + allowsFreeform: isPlanApproval, blocking: true, canProceedWithoutAnswer: false, providerMetadata: { @@ -4820,8 +5070,8 @@ export function createAgentChatService(args: { turnId, }; emitPendingInputRequest(managed, request, { - kind: category === "bash" ? "command" : "file_change", - description, + kind: isPlanApproval ? "tool_call" : category === "bash" ? "command" : "file_change", + description: isPlanApproval ? "Plan ready for approval" : description, detail: detail && typeof detail === "object" && !Array.isArray(detail) ? { ...(detail as Record) } : {}, @@ -4924,6 +5174,13 @@ export function createAgentChatService(args: { defaultLaneId: managed.session.laneId, defaultModelId: managed.session.modelId ?? null, defaultReasoningEffort: managed.session.reasoningEffort ?? null, + resolveExecutionLane: async ({ requestedLaneId, purpose, freshLaneName, freshLaneDescription }) => + requestExecutionLaneForIdentitySession(managed, { + requestedLaneId, + purpose, + freshLaneName, + freshLaneDescription, + }), laneService, missionService: getMissionService?.() ?? null, aiOrchestratorService: getAiOrchestratorService?.() ?? null, @@ -4951,6 +5208,7 @@ export function createAgentChatService(args: { modelId, reasoningEffort, reuseExisting, + permissionMode: "full-auto", }), })); } @@ -5193,11 +5451,12 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - await maybeDrainQueuedSteer( - managed, - runtime.pendingSteers, - async (steerText) => runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), - ); + if (!managed.closed && runtime.pendingSteers.length) { + const steerText = runtime.pendingSteers.shift() ?? ""; + if (steerText.trim().length) { + await runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); + } + } } catch (error) { clearTimeout(turnTimeout); runtime.busy = false; @@ -5219,8 +5478,8 @@ export function createAgentChatService(args: { const { message: errorMessage, errorInfo } = classifyUnifiedError( error, - runtime.modelDescriptor?.family ?? "unknown", - runtime.modelDescriptor?.displayName ?? managed.session.model, + runtime.modelDescriptor.family, + runtime.modelDescriptor.displayName, ); emitChatEvent(managed, { @@ -5248,11 +5507,6 @@ export function createAgentChatService(args: { } persistChatState(managed); - await maybeDrainQueuedSteer( - managed, - runtime.pendingSteers, - async (steerText) => runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), - ); } }; @@ -5768,16 +6022,8 @@ export function createAgentChatService(args: { const method = typeof payload.method === "string" ? payload.method : ""; const params = (payload.params as Record | null) ?? {}; const turnIdFromParams = extractCodexTurnId(params); - const threadIdFromParams = extractCodexThreadId(params); - const itemIdFromParams = extractCodexItemId(params); - - if (threadIdFromParams) { - setCodexThreadIdentity(managed, runtime, threadIdFromParams); - } - if (method === "thread/started") { - runtime.threadResumed = true; - persistChatState(managed); + if (shouldSkipDuplicateCodexNotification(runtime, payload)) { return; } @@ -5785,6 +6031,10 @@ export function createAgentChatService(args: { const turn = (params.turn as { id?: unknown } | null) ?? null; const turnId = typeof turn?.id === "string" ? turn.id : null; runtime.activeTurnId = turnId; + resetAssistantMessageStream(managed); + runtime.agentMessageScopeByTurn.clear(); + runtime.agentMessageTextByTurn.clear(); + runtime.recentNotificationKeys.clear(); managed.session.status = "active"; if (!turnId || runtime.startedTurnId !== turnId) { const reasoningActivity = sessionSupportsReasoning(managed.session) @@ -5817,11 +6067,18 @@ export function createAgentChatService(args: { const resolvedTurnId = typeof turn?.id === "string" ? turn.id : runtime.activeTurnId ?? undefined; if (!resolvedTurnId) { logger.warn(`[codex] turn/completed missing turnId for session ${managed.session.id}`); + } else if (!isCurrentCodexLifecycleTurn(runtime, resolvedTurnId)) { + logger.warn(`[codex] ignoring turn/completed for inactive turn ${resolvedTurnId} in session ${managed.session.id}`); + return; } const turnId = resolvedTurnId ?? randomUUID(); runtime.activeTurnId = null; runtime.startedTurnId = null; + resetAssistantMessageStream(managed); runtime.itemTurnIdByItemId.clear(); + runtime.agentMessageScopeByTurn.clear(); + runtime.agentMessageTextByTurn.clear(); + runtime.recentNotificationKeys.clear(); const status = mapCodexTurnStatus(turn?.status); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); managed.session.status = "idle"; @@ -5858,41 +6115,65 @@ export function createAgentChatService(args: { sessionService.setHeadShaEnd(managed.session.id, endSha); } - if (runtime.pendingThreadRebind) { - runtime.pendingThreadRebind = false; - runtime.threadResumed = false; - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Codex settings updated for the next turn.", - detail: "Approval, sandbox, or config source changed while this turn was running. ADE will rebind the thread with those settings on the next message.", - }); - } - persistChatState(managed); return; } if (method === "item/agentMessage/delta") { - const delta = extractCodexTextPayload(params) ?? ""; + const delta = String((params.delta as string | undefined) ?? ""); if (!delta.length) return; + const turnId = typeof params.turnId === "string" + ? params.turnId + : runtime.activeTurnId ?? undefined; + const itemId = typeof params.itemId === "string" ? params.itemId : undefined; + const turnScopeKey = turnId ?? (itemId ? `item:${itemId}` : null); + if (turnScopeKey) { + const nextScope: "item" | "turn" = itemId ? "item" : "turn"; + const existingScope = runtime.agentMessageScopeByTurn.get(turnScopeKey) ?? null; + if (nextScope === "turn") { + if (existingScope !== "turn") { + runtime.agentMessageScopeByTurn.set(turnScopeKey, "turn"); + if (turnId && managed.bufferedText?.turnId === turnId && managed.bufferedText.itemId) { + discardBufferedAssistantText(managed); + } + } + } else if (existingScope === "turn") { + return; + } else { + runtime.agentMessageScopeByTurn.set(turnScopeKey, "item"); + } + } + // Always emit with turnId when available — the Codex CLI may stop + // providing itemId mid-stream, but turnId from runtime.activeTurnId + // ensures the renderer can still merge consecutive text deltas into + // one bubble. Without this, the collapse logic sees mismatched + // identity attributes and creates separate rows per delta. + const emitTurnId = turnId ?? runtime.activeTurnId ?? undefined; + const normalizedDelta = normalizeCodexAssistantDelta(runtime, { + delta, + ...(emitTurnId ? { turnId: emitTurnId } : {}), + ...(itemId ? { itemId } : {}), + }); + if (!normalizedDelta?.length) { + return; + } emitChatEvent(managed, { type: "text", - text: delta, - turnId: turnIdFromParams ?? undefined, - itemId: itemIdFromParams ?? undefined, + text: normalizedDelta, + ...(emitTurnId ? { turnId: emitTurnId } : {}), + ...(itemId ? { itemId } : {}), }); return; } if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { - const delta = extractCodexTextPayload(params) ?? ""; + const delta = String((params.delta as string | undefined) ?? ""); if (!delta.length) return; emitChatEvent(managed, { type: "reasoning", text: delta, - turnId: turnIdFromParams ?? undefined, - itemId: itemIdFromParams ?? undefined, + turnId: typeof params.turnId === "string" ? params.turnId : undefined, + itemId: typeof params.itemId === "string" ? params.itemId : undefined, summaryIndex: typeof params.summaryIndex === "number" ? params.summaryIndex : undefined }); return; @@ -5985,6 +6266,51 @@ export function createAgentChatService(args: { turnId: typeof params.turnId === "string" ? params.turnId : undefined, explanation: typeof params.explanation === "string" ? params.explanation : null }); + + // Emit plan approval request when the session is in plan mode and a + // complete plan has been proposed (all steps still pending = freshly created). + if (managed.session.permissionMode === "plan" && steps.length > 0) { + const allPending = steps.every((s) => s.status === "pending"); + if (allPending) { + const planSummary = steps.map((s, i) => `${i + 1}. ${s.text}`).join("\n"); + const planApprovalItemId = randomUUID(); + const planTurnId = typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined; + const request: PendingInputRequest = { + requestId: planApprovalItemId, + itemId: planApprovalItemId, + source: "codex", + kind: "plan_approval", + title: "Plan Ready for Review", + description: planSummary, + questions: [{ + id: "plan_decision", + header: "Implementation Plan", + question: planSummary, + options: [ + { label: "Approve & Implement", value: "approve", recommended: true }, + { label: "Reject & Revise", value: "reject" }, + ], + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { tool: "codexPlanApproval" }, + turnId: planTurnId ?? null, + }; + runtime.approvals.set(planApprovalItemId, { + requestId: planApprovalItemId, + kind: "plan_approval", + request, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: "Plan ready for approval", + detail: { planContent: planSummary }, + }); + } + } + return; } @@ -6018,22 +6344,18 @@ export function createAgentChatService(args: { const resolvedAbortTurnId = turnIdFromParams ?? runtime.activeTurnId ?? undefined; if (!resolvedAbortTurnId) { logger.warn(`[codex] turn/aborted missing turnId for session ${managed.session.id}`); + } else if (!isCurrentCodexLifecycleTurn(runtime, resolvedAbortTurnId)) { + logger.warn(`[codex] ignoring turn/aborted for inactive turn ${resolvedAbortTurnId} in session ${managed.session.id}`); + return; } const turnId = resolvedAbortTurnId ?? randomUUID(); runtime.activeTurnId = null; runtime.startedTurnId = null; - runtime.itemTurnIdByItemId.clear(); + resetAssistantMessageStream(managed); + runtime.agentMessageScopeByTurn.clear(); + runtime.agentMessageTextByTurn.clear(); + runtime.recentNotificationKeys.clear(); managed.session.status = "idle"; - if (runtime.pendingThreadRebind) { - runtime.pendingThreadRebind = false; - runtime.threadResumed = false; - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Codex settings updated for the next turn.", - detail: "This turn was interrupted after settings changed. ADE will rebind the thread with those settings on the next message.", - }); - } emitChatEvent(managed, { type: "status", turnStatus: "interrupted", @@ -6051,7 +6373,7 @@ export function createAgentChatService(args: { } if (method === "codex/event/web_search_begin") { - const query = pickCodexStringId(params.query, params.searchQuery, params.input) ?? ""; + const query = pickCodexTurnId(params.query, params.searchQuery, params.input) ?? ""; emitChatEvent(managed, { type: "activity", activity: "web_searching", @@ -6072,26 +6394,7 @@ export function createAgentChatService(args: { method === "thread/status/changed" || method === "codex/event/task_started" || method === "codex/event/mcp_startup_update" - || method === "codex/event/task_complete" - || method === "codex/event/token_count" - || method === "thread/tokenUsage/updated" - ) { - return; - } - - if ( - method === "codex/event/agent_message" - || method === "codex/event/agent_message_delta" - || method === "codex/event/agent_message_content_delta" ) { - const text = extractCodexTextPayload(params); - if (!text) return; - emitChatEvent(managed, { - type: "text", - text, - turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, - itemId: itemIdFromParams ?? undefined, - }); return; } @@ -6231,12 +6534,13 @@ export function createAgentChatService(args: { activeTurnId: null, startedTurnId: null, threadResumed: false, - pendingThreadRebind: false, - threadIdWaiters: new Set(), itemTurnIdByItemId: new Map(), commandOutputByItemId: new Map(), fileDeltaByItemId: new Map(), fileChangesByItemId: new Map>(), + agentMessageScopeByTurn: new Map(), + agentMessageTextByTurn: new Map(), + recentNotificationKeys: new Set(), slashCommands: [], rateLimits: null, request: async (method: string, params?: unknown): Promise => { @@ -6426,6 +6730,7 @@ export function createAgentChatService(args: { delete managed.session.codexApprovalPolicy; delete managed.session.codexSandbox; } + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const mcpServers = isLightweightSession(managed.session) ? {} : buildAdeMcpServers( @@ -6454,15 +6759,12 @@ export function createAgentChatService(args: { experimentalRawEvents: false, persistExtendedHistory: true }); - const newThreadId = setCodexThreadIdentity(managed, runtime, extractCodexThreadId(startResponse)); - if (!newThreadId && !managed.session.threadId) { - const recoveredThreadId = await waitForCodexThreadIdentity(runtime); - if (recoveredThreadId) { - setCodexThreadIdentity(managed, runtime, recoveredThreadId); - } + const newThreadId = typeof startResponse.thread?.id === "string" ? startResponse.thread.id : undefined; + if (newThreadId) { + managed.session.threadId = newThreadId; + sessionService.setResumeCommand(managed.session.id, `chat:codex:${newThreadId}`); } runtime.threadResumed = true; - runtime.pendingThreadRebind = false; persistChatState(managed); // Fetch available skills and populate slash commands @@ -6503,6 +6805,7 @@ export function createAgentChatService(args: { chatConfig.claudePermissionMode, ); managed.session.claudePermissionMode = claudePermissionMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const lightweight = isLightweightSession(managed.session); const claudeExecutable = resolveClaudeCodeExecutable(); const opts: ClaudeSDKOptions = { @@ -6536,7 +6839,7 @@ export function createAgentChatService(args: { managed.session.id, managed.session.computerUse, ) as any; - opts.canUseTool = buildClaudeCanUseTool(runtime) as any; + opts.canUseTool = buildClaudeCanUseTool(runtime, managed) as any; // Enable MCP tool search for sessions with many MCP tools. // When enabled, the SDK defers tool definitions and loads them on-demand @@ -6569,6 +6872,18 @@ export function createAgentChatService(args: { return { ...opts, model }; }; + const resolveClaudeTurnPermissionMode = ( + managed: ManagedChatSession, + ): AgentChatClaudePermissionMode => { + const chatConfig = resolveChatConfig(); + const interactionMode = resolveSessionClaudeInteractionMode(managed.session); + const accessMode = resolveSessionClaudePermissionMode(managed.session, chatConfig.claudePermissionMode); + managed.session.interactionMode = interactionMode; + managed.session.claudePermissionMode = accessMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + return interactionMode === "plan" ? "plan" : accessMode; + }; + const cancelClaudeWarmup = ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -6781,7 +7096,6 @@ export function createAgentChatService(args: { laneId: "temporary", provider: "codex", model: DEFAULT_CODEX_MODEL, - modelId: fallbackModelIdForProvider("codex"), capabilityMode: "full_mcp", status: "idle", createdAt: nowIso(), @@ -6803,11 +7117,18 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + summaryInFlight: false, + continuitySummary: null, + continuitySummaryUpdatedAt: null, + continuitySummaryInFlight: false, + preferredExecutionLaneId: null, + selectedExecutionLaneId: null, + activeAssistantMessageId: null, previewTextBuffer: null, bufferedText: null, recentConversationEntries: [], + localPendingInputs: new Map(), eventSequence: 0, - recoveryState: createRecoveryState(), }; let runtime: CodexRuntime | null = null; @@ -6938,11 +7259,13 @@ export function createAgentChatService(args: { modelId, sessionProfile, reasoningEffort, + interactionMode: requestedInteractionMode, claudePermissionMode: requestedClaudePermissionMode, codexApprovalPolicy: requestedCodexApprovalPolicy, codexSandbox: requestedCodexSandbox, codexConfigSource: requestedCodexConfigSource, unifiedPermissionMode: requestedUnifiedPermissionMode, + permissionMode: requestedPermMode, identityKey, surface, automationId, @@ -6964,17 +7287,15 @@ export function createAgentChatService(args: { ? DEFAULT_CLAUDE_MODEL : ""); // Resolve modelId from registry if provided - const inferredModelId = modelId && getModelById(modelId) + const resolvedModelId = modelId && getModelById(modelId) ? modelId : resolveModelIdFromStoredValue(normalizedInputModel, provider); - const resolvedModelId = inferredModelId ?? (provider === "unified" ? undefined : fallbackModelIdForProvider(provider)); if (provider === "unified" && !resolvedModelId) { throw new Error("Unified chat requires a known model ID. Select a model from the registry."); } - const ensuredModelId = resolvedModelId ?? fallbackModelIdForProvider(provider); - const resolvedDescriptor = getModelById(ensuredModelId); + const resolvedDescriptor = resolvedModelId ? getModelById(resolvedModelId) : undefined; if (resolvedModelId && !resolvedDescriptor) { throw new Error(`Unknown model '${resolvedModelId}'.`); } @@ -6990,7 +7311,7 @@ export function createAgentChatService(args: { ); } effectiveProvider = resolved; - normalizedModel = getRuntimeModelRefForDescriptor(resolvedDescriptor, resolved); + normalizedModel = resolvedDescriptor.isCliWrapped ? resolvedDescriptor.shortId : resolvedDescriptor.id; } const rawEffort = effectiveProvider === "codex" @@ -7001,27 +7322,50 @@ export function createAgentChatService(args: { : validateReasoningEffort(effectiveProvider === "claude" ? "claude" : "codex", rawEffort); const capabilityMode = inferCapabilityMode(effectiveProvider); const computerUsePolicy = normalizeComputerUsePolicy(computerUse, createDefaultComputerUsePolicy()); + const effectivePermissionMode = identityKey + ? normalizeIdentityPermissionMode(requestedPermMode, effectiveProvider) + : requestedPermMode; const chatConfig = resolveChatConfig(); const nativePermissionFields = (() => { if (effectiveProvider === "claude") { - return { - claudePermissionMode: requestedClaudePermissionMode ?? chatConfig.claudePermissionMode, - }; + const interactionMode = requestedInteractionMode + ?? (requestedClaudePermissionMode === "plan" ? "plan" : undefined) + ?? (effectivePermissionMode === "plan" ? "plan" : undefined) + ?? (chatConfig.claudePermissionMode === "plan" ? "plan" : undefined) + ?? "default"; + const claudePermissionMode = requestedClaudePermissionMode + ? resolveSessionClaudeAccessMode( + { claudePermissionMode: requestedClaudePermissionMode, permissionMode: undefined }, + chatConfig.claudePermissionMode, + ) + : resolveSessionClaudeAccessMode( + { claudePermissionMode: undefined, permissionMode: effectivePermissionMode }, + chatConfig.claudePermissionMode, + ); + return { interactionMode, claudePermissionMode }; } if (effectiveProvider === "codex") { - const codexConfigSource = requestedCodexConfigSource ?? "flags"; + const codexConfigSource = requestedCodexConfigSource + ?? legacyPermissionModeToCodexConfigSource(effectivePermissionMode) + ?? "flags"; if (codexConfigSource === "config-toml") { return { codexConfigSource }; } return { - codexApprovalPolicy: requestedCodexApprovalPolicy ?? chatConfig.codexApprovalPolicy, - codexSandbox: requestedCodexSandbox ?? chatConfig.codexSandboxMode, + codexApprovalPolicy: requestedCodexApprovalPolicy + ?? legacyPermissionModeToCodexApprovalPolicy(effectivePermissionMode) + ?? chatConfig.codexApprovalPolicy, + codexSandbox: requestedCodexSandbox + ?? legacyPermissionModeToCodexSandbox(effectivePermissionMode) + ?? chatConfig.codexSandboxMode, codexConfigSource, }; } return { - unifiedPermissionMode: requestedUnifiedPermissionMode ?? chatConfig.unifiedPermissionMode, + unifiedPermissionMode: requestedUnifiedPermissionMode + ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) + ?? chatConfig.unifiedPermissionMode, }; })(); @@ -7043,10 +7387,11 @@ export function createAgentChatService(args: { laneId, provider: effectiveProvider, model: normalizedModel, - modelId: ensuredModelId, + ...(resolvedModelId ? { modelId: resolvedModelId } : {}), sessionProfile: sessionProfile ?? "workflow", ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), ...nativePermissionFields, + ...(effectivePermissionMode ? { permissionMode: effectivePermissionMode } : {}), ...(identityKey ? { identityKey } : {}), surface: surface ?? "work", automationId: automationId?.trim() ? automationId.trim() : null, @@ -7072,17 +7417,24 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + summaryInFlight: false, + continuitySummary: null, + continuitySummaryUpdatedAt: null, + continuitySummaryInFlight: false, + preferredExecutionLaneId: null, + selectedExecutionLaneId: null, + activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, previewTextBuffer: null, bufferedText: null, recentConversationEntries: [], + localPendingInputs: new Map(), eventSequence: 0, - recoveryState: createRecoveryState(), }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; - refreshReconstructionContext(managed); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); // Init dedicated chat transcript file for persistence try { @@ -7148,7 +7500,7 @@ export function createAgentChatService(args: { } const targetProvider = resolveProviderGroupForModel(targetDescriptor); - const targetModel = getRuntimeModelRefForDescriptor(targetDescriptor, targetProvider); + const targetModel = targetDescriptor.isCliWrapped ? targetDescriptor.shortId : targetDescriptor.id; const targetReasoningEffort = pickHandoffReasoningEffort( targetDescriptor, managed.session.reasoningEffort ?? sourceSession.reasoningEffort, @@ -7174,17 +7526,20 @@ export function createAgentChatService(args: { modelId: targetDescriptor.id, sessionProfile: managed.session.sessionProfile, reasoningEffort: targetReasoningEffort, + interactionMode: managed.session.interactionMode, claudePermissionMode: managed.session.claudePermissionMode, codexApprovalPolicy: managed.session.codexApprovalPolicy, codexSandbox: managed.session.codexSandbox, codexConfigSource: managed.session.codexConfigSource, unifiedPermissionMode: managed.session.unifiedPermissionMode, + permissionMode: managed.session.permissionMode, surface: managed.session.surface, computerUse: managed.session.computerUse, }); const createdManaged = ensureManagedSession(created.id); createdManaged.session.executionMode = managed.session.executionMode ?? sourceSession.executionMode ?? null; + createdManaged.session.interactionMode = managed.session.interactionMode ?? sourceSession.interactionMode ?? null; const inheritedGoal = trimLine(sourceSession.goal) ?? trimLine(sourceSession.summary) ?? trimLine(sourceSession.title); @@ -7202,6 +7557,9 @@ export function createAgentChatService(args: { displayText: "Chat handoff from previous session", reasoningEffort: targetReasoningEffort, executionMode: createdManaged.session.executionMode ?? null, + interactionMode: createdManaged.session.interactionMode ?? null, + }, { + awaitDispatch: true, }); return { @@ -7217,6 +7575,7 @@ export function createAgentChatService(args: { attachments = [], reasoningEffort, executionMode, + interactionMode, }: AgentChatSendArgs): PreparedSendMessage | null => { const trimmed = text.trim(); if (!trimmed.length) return null; @@ -7242,7 +7601,7 @@ export function createAgentChatService(args: { managed.closed = false; managed.endedNotified = false; managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; - refreshReconstructionContext(managed); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); } if (!managed.autoTitleSeed) { @@ -7252,10 +7611,15 @@ export function createAgentChatService(args: { latestUserText: visibleText, }); } + if (managed.session.provider === "claude") { + managed.session.interactionMode = interactionMode ?? managed.session.interactionMode ?? "default"; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + } const promptText = isLiteralSlashCommand(trimmed) ? trimmed : composeLaunchDirectives(trimmed, [ buildExecutionModeDirective(executionMode, managed.session.provider), + buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), buildComputerUseDirective( managed.session.computerUse, computerUseArtifactBrokerRef?.getBackendStatus() ?? null, @@ -7274,6 +7638,7 @@ export function createAgentChatService(args: { visibleText, attachments, reasoningEffort, + interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, }; }; @@ -7282,21 +7647,32 @@ export function createAgentChatService(args: { if (managed.closed) return; const message = error instanceof Error ? error.message : String(error); - const normalizedMessage = message.toLowerCase(); const turnId = randomUUID(); - managed.session.status = "idle"; - if (managed.runtime?.kind === "codex") { + // If the failure is "turn already active", the original turn is still running. + // Do NOT clear activeTurnId or runtime state — that would corrupt the in-flight + // turn's streaming (text deltas lose their turnId and each word becomes a + // separate chat bubble). + const normalizedMsg = message.toLowerCase(); + const isBusyError = normalizedMsg.includes("turn is already active") + || normalizedMsg.includes("already active") + || normalizedMsg.includes("busy"); + + if (!isBusyError) { + managed.session.status = "idle"; + } + + if (managed.runtime?.kind === "codex" && !isBusyError) { managed.runtime.activeTurnId = null; managed.runtime.startedTurnId = null; managed.runtime.itemTurnIdByItemId.clear(); } - if (managed.runtime?.kind === "unified") { + if (managed.runtime?.kind === "unified" && !isBusyError) { managed.runtime.busy = false; managed.runtime.activeTurnId = null; managed.runtime.abortController = null; } - if (managed.runtime?.kind === "claude") { + if (managed.runtime?.kind === "claude" && !isBusyError) { managed.runtime.busy = false; managed.runtime.activeTurnId = null; managed.runtime.activeQuery = null; @@ -7307,33 +7683,6 @@ export function createAgentChatService(args: { message, turnId, }); - if ( - normalizedMessage.includes("missing thread id") - || normalizedMessage.includes("lost its thread") - || (normalizedMessage.includes("session") && normalizedMessage.includes("missing")) - ) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "thread_error", - message: "This chat session hit a thread-level failure.", - detail: message, - turnId, - }); - } else if ( - normalizedMessage.includes("auth") - || normalizedMessage.includes("authentication") - || normalizedMessage.includes("network") - || normalizedMessage.includes("timed out") - || normalizedMessage.includes("rate limit") - ) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "provider_health", - message: `${managed.session.provider} runtime issue`, - detail: message, - turnId, - }); - } emitChatEvent(managed, { type: "status", turnStatus: "failed", @@ -7363,6 +7712,7 @@ export function createAgentChatService(args: { visibleText, attachments, reasoningEffort, + onDispatched, } = prepared; // Unified runtime dispatch @@ -7376,7 +7726,7 @@ export function createAgentChatService(args: { if (reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } - await runTurn(managed, { promptText, displayText: visibleText, attachments }); + await runTurn(managed, { promptText, displayText: visibleText, attachments, onDispatched }); return; } @@ -7405,9 +7755,8 @@ export function createAgentChatService(args: { ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); - setCodexThreadIdentity(managed, runtime, threadIdToResume); + managed.session.threadId = threadIdToResume; runtime.threadResumed = true; - runtime.pendingThreadRebind = false; // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) @@ -7444,7 +7793,7 @@ export function createAgentChatService(args: { } } - await sendCodexMessage(managed, { promptText, displayText: visibleText, attachments }); + await sendCodexMessage(managed, { promptText, displayText: visibleText, attachments, onDispatched }); return; } @@ -7454,13 +7803,32 @@ export function createAgentChatService(args: { } ensureClaudeSessionRuntime(managed); - await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments }); + await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments, onDispatched }); }; - const sendMessage = async (args: AgentChatSendArgs): Promise => { + const sendMessage = async ( + args: AgentChatSendArgs, + options?: { awaitDispatch?: boolean }, + ): Promise => { const dispatchStartedAt = Date.now(); const prepared = prepareSendMessage(args); if (!prepared) return; + let rejectDispatch: ((error: Error) => void) | null = null; + const dispatchPromise = options?.awaitDispatch + ? new Promise((resolve, reject) => { + let settled = false; + prepared.onDispatched = () => { + if (settled) return; + settled = true; + resolve(); + }; + rejectDispatch = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + }) + : null; logger.info("agent_chat.turn_dispatch_ack", { sessionId: prepared.sessionId, @@ -7475,8 +7843,13 @@ export function createAgentChatService(args: { provider: prepared.managed.session.provider, error: error instanceof Error ? error.message : String(error), }); + rejectDispatch?.(error instanceof Error ? error : new Error(String(error))); emitDispatchedSendFailure(prepared, error); }); + + if (dispatchPromise) { + await dispatchPromise; + } }; const steer = async ({ sessionId, text }: AgentChatSteerArgs): Promise => { @@ -7503,13 +7876,13 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "user_message", text: trimmed, - deliveryState: "queued", + turnId: runtime.activeTurnId ?? undefined, }); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", - message: "Message queued for the next turn.", - detail: "ADE is still inside the current turn, so this follow-up will send as soon as that turn finishes.", + message: "Message queued — will be sent when the current turn completes.", + turnId: runtime.activeTurnId ?? undefined, }); persistChatState(managed); return; @@ -7560,15 +7933,13 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "user_message", text: trimmed, - deliveryState: "queued", + turnId: runtime.activeTurnId ?? undefined, }); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", - message: "Message queued for the next turn.", - detail: runtime.activeSubagents.size > 0 - ? `Claude is still busy in the current turn with ${runtime.activeSubagents.size} active subagent${runtime.activeSubagents.size === 1 ? "" : "s"}, so ADE will send this follow-up after that turn finishes.` - : "Claude is still busy in the current turn, so ADE will send this follow-up after that turn finishes.", + message: "Message queued — will be sent when the current turn completes.", + turnId: runtime.activeTurnId ?? undefined, }); persistChatState(managed); return; @@ -7611,15 +7982,8 @@ export function createAgentChatService(args: { }); runtime.interrupted = true; cancelClaudeWarmup(managed, runtime, "interrupt"); - const streamGen = runtime.v2StreamGen; - if (streamGen && typeof streamGen.return === "function") { - try { - await streamGen.return(undefined as never); - } catch { - // ignore stream termination failures during interrupt - } - } - // Close the V2 session on interrupt — it will be recreated on the next turn. + runtime.activeQuery?.interrupt().catch(() => {}); + // Close the V2 session on interrupt — it will be recreated on the next turn try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; runtime.v2StreamGen = null; @@ -7636,7 +8000,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(sessionId); const persisted = readPersistedState(sessionId); managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider); - refreshReconstructionContext(managed); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); @@ -7657,9 +8021,9 @@ export function createAgentChatService(args: { ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); - setCodexThreadIdentity(managed, runtime, threadId); + managed.session.threadId = threadId; runtime.threadResumed = true; - runtime.pendingThreadRebind = false; + sessionService.setResumeCommand(sessionId, `chat:codex:${threadId}`); // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) @@ -7702,6 +8066,7 @@ export function createAgentChatService(args: { managed.runtime.messages = persistedMessages.map((m) => ({ role: m.role, content: m.content })); } managed.session.unifiedPermissionMode = persisted?.unifiedPermissionMode ?? managed.session.unifiedPermissionMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( managed.session, resolveChatConfig().unifiedPermissionMode, @@ -7735,9 +8100,11 @@ export function createAgentChatService(args: { row: ReturnType["list"]>[number], ): AgentChatSessionSummary => { const persisted = readPersistedState(row.id); - const provider = persisted?.provider ?? providerFromToolType(row.toolType); - const fallbackModel = persisted?.model ?? fallbackModelForProvider(provider); - const hydratedModelId = persisted?.modelId + const liveSession = managedSessions.get(row.id)?.session ?? null; + const provider = liveSession?.provider ?? persisted?.provider ?? providerFromToolType(row.toolType); + const fallbackModel = liveSession?.model ?? persisted?.model ?? fallbackModelForProvider(provider); + const hydratedModelId = liveSession?.modelId + ?? persisted?.modelId ?? resolveModelIdFromStoredValue(fallbackModel, provider) ?? (provider === "unified" ? DEFAULT_UNIFIED_MODEL_ID : undefined); const model = provider === "unified" ? (hydratedModelId ?? fallbackModel) : fallbackModel; @@ -7747,29 +8114,48 @@ export function createAgentChatService(args: { provider, model, ...(hydratedModelId ? { modelId: hydratedModelId } : {}), + sessionProfile: liveSession?.sessionProfile ?? persisted?.sessionProfile, title: row.title ?? null, goal: row.goal ?? null, - reasoningEffort: persisted?.reasoningEffort ?? null, - executionMode: persisted?.executionMode ?? null, - ...(persisted?.claudePermissionMode ? { claudePermissionMode: persisted.claudePermissionMode } : {}), - ...(persisted?.codexApprovalPolicy ? { codexApprovalPolicy: persisted.codexApprovalPolicy } : {}), - ...(persisted?.codexSandbox ? { codexSandbox: persisted.codexSandbox } : {}), - ...(persisted?.codexConfigSource ? { codexConfigSource: persisted.codexConfigSource } : {}), - ...(persisted?.unifiedPermissionMode ? { unifiedPermissionMode: persisted.unifiedPermissionMode } : {}), - ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), - surface: persisted?.surface ?? "work", - automationId: persisted?.automationId ?? null, - automationRunId: persisted?.automationRunId ?? null, - capabilityMode: persisted?.capabilityMode ?? inferCapabilityMode(provider), - computerUse: normalizePersistedComputerUse(persisted?.computerUse), - completion: persisted?.completion ?? null, - status: row.status === "running" ? "idle" : "ended", + reasoningEffort: liveSession?.reasoningEffort ?? persisted?.reasoningEffort ?? null, + executionMode: liveSession?.executionMode ?? persisted?.executionMode ?? null, + interactionMode: liveSession?.interactionMode ?? persisted?.interactionMode ?? null, + ...(liveSession?.claudePermissionMode || persisted?.claudePermissionMode + ? { claudePermissionMode: liveSession?.claudePermissionMode ?? persisted?.claudePermissionMode } + : {}), + ...(liveSession?.codexApprovalPolicy || persisted?.codexApprovalPolicy + ? { codexApprovalPolicy: liveSession?.codexApprovalPolicy ?? persisted?.codexApprovalPolicy } + : {}), + ...(liveSession?.codexSandbox || persisted?.codexSandbox + ? { codexSandbox: liveSession?.codexSandbox ?? persisted?.codexSandbox } + : {}), + ...(liveSession?.codexConfigSource || persisted?.codexConfigSource + ? { codexConfigSource: liveSession?.codexConfigSource ?? persisted?.codexConfigSource } + : {}), + ...(liveSession?.unifiedPermissionMode || persisted?.unifiedPermissionMode + ? { unifiedPermissionMode: liveSession?.unifiedPermissionMode ?? persisted?.unifiedPermissionMode } + : {}), + ...(liveSession?.permissionMode || persisted?.permissionMode + ? { permissionMode: liveSession?.permissionMode ?? persisted?.permissionMode } + : {}), + ...(liveSession?.identityKey || persisted?.identityKey + ? { identityKey: liveSession?.identityKey ?? persisted?.identityKey } + : {}), + surface: liveSession?.surface ?? persisted?.surface ?? "work", + automationId: liveSession?.automationId ?? persisted?.automationId ?? null, + automationRunId: liveSession?.automationRunId ?? persisted?.automationRunId ?? null, + capabilityMode: liveSession?.capabilityMode ?? persisted?.capabilityMode ?? inferCapabilityMode(provider), + computerUse: liveSession?.computerUse ?? normalizePersistedComputerUse(persisted?.computerUse), + completion: liveSession?.completion ?? persisted?.completion ?? null, + status: liveSession?.status ?? (row.status === "running" ? "idle" : "ended"), startedAt: row.startedAt, endedAt: row.endedAt, - lastActivityAt: persisted?.updatedAt ?? row.endedAt ?? row.startedAt, + lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, lastOutputPreview: row.lastOutputPreview, - summary: row.summary ?? persisted?.completion?.summary ?? null, - ...(persisted?.threadId ? { threadId: persisted.threadId } : {}) + summary: row.summary ?? liveSession?.completion?.summary ?? persisted?.completion?.summary ?? null, + ...(liveSession?.threadId || persisted?.threadId + ? { threadId: liveSession?.threadId ?? persisted?.threadId } + : {}) } satisfies AgentChatSessionSummary; }; @@ -7801,20 +8187,36 @@ export function createAgentChatService(args: { laneId: string; modelId?: string | null; reasoningEffort?: string | null; + permissionMode?: AgentChatSession["permissionMode"]; reuseExisting?: boolean; }): Promise => { - const laneId = args.laneId.trim(); - if (!laneId.length) { + const requestedLaneId = args.laneId.trim(); + if (!requestedLaneId.length) { throw new Error("laneId is required to ensure an identity-bound chat session."); } - const existing = args.reuseExisting === false - ? [] - : (await listSessions(undefined, { includeIdentity: true })) - .filter((entry) => entry.identityKey === args.identityKey) - .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); + const canonicalLaneId = await resolvePrimaryIdentityLane(); + const selectedExecutionLaneId = requestedLaneId || null; + const existing = await listSessions(undefined, { includeIdentity: true }); + const identitySessions = existing + .filter((entry) => entry.identityKey === args.identityKey) + .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); + + const canonicalExisting = args.reuseExisting === false + ? null + : identitySessions.find((entry) => entry.laneId === canonicalLaneId) ?? null; + const legacySessions = identitySessions.filter((entry) => entry.laneId !== canonicalLaneId && entry.status !== "ended"); + + const retireLegacySessions = async (): Promise => { + for (const legacy of legacySessions) { + const legacyManaged = ensureManagedSession(legacy.sessionId); + await finishSession(legacyManaged, "disposed", { + summary: "Superseded by the canonical primary-hosted identity session.", + }); + } + }; - const preferred = existing.find((entry) => entry.laneId === laneId) ?? existing[0] ?? null; + const preferred = canonicalExisting; if (preferred) { const managed = ensureManagedSession(preferred.sessionId); managed.session.identityKey = args.identityKey; @@ -7822,9 +8224,16 @@ export function createAgentChatService(args: { if (args.reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(args.reasoningEffort); } + managed.session.permissionMode = normalizeIdentityPermissionMode( + args.permissionMode ?? managed.session.permissionMode, + managed.session.provider, + ); + applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); - refreshReconstructionContext(managed); + managed.selectedExecutionLaneId = selectedExecutionLaneId ?? managed.selectedExecutionLaneId; + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); persistChatState(managed); + await retireLegacySessions(); if (managed.session.status === "ended") { await resumeSession({ sessionId: managed.session.id }); @@ -7876,26 +8285,21 @@ export function createAgentChatService(args: { ? workerAdapterConfig.model.trim() : fallbackModelForProvider(provider); - // Identity sessions default to full-auto via provider-native fields. - const identityPermissionFields = (() => { - if (provider === "claude") return { claudePermissionMode: "bypassPermissions" as const }; - if (provider === "codex") return { codexApprovalPolicy: "never" as const, codexSandbox: "danger-full-access" as const }; - return { unifiedPermissionMode: "full-auto" as const }; - })(); - const created = await createSession({ - laneId, + laneId: canonicalLaneId, provider, model: preferredModel, ...(resolvedModelId ? { modelId: resolvedModelId } : {}), reasoningEffort: args.reasoningEffort ?? pref?.reasoningEffort ?? null, - ...identityPermissionFields, + permissionMode: args.permissionMode ?? "plan", identityKey: args.identityKey }); const managed = ensureManagedSession(created.id); - refreshReconstructionContext(managed); + managed.selectedExecutionLaneId = selectedExecutionLaneId; + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); persistChatState(managed); + await retireLegacySessions(); return managed.session; }; @@ -7907,6 +8311,12 @@ export function createAgentChatService(args: { responseText, }: AgentChatRespondToInputArgs): Promise => { const managed = ensureManagedSession(sessionId); + const localPending = managed.localPendingInputs.get(itemId); + if (localPending) { + managed.localPendingInputs.delete(itemId); + localPending.resolve({ decision, answers, responseText }); + return; + } if (managed.runtime?.kind === "codex") { const pending = managed.runtime.approvals.get(itemId); @@ -7914,6 +8324,32 @@ export function createAgentChatService(args: { throw new Error(`No pending approval found for item '${itemId}'.`); } managed.runtime.approvals.delete(itemId); + + // Plan approval is created locally (not a JSON-RPC server request). + // On approve, send a follow-up turn telling Codex to implement. + // On reject, send feedback for revision. + if (pending.kind === "plan_approval") { + const approved = decision === "accept" || decision === "accept_for_session"; + const feedback = typeof responseText === "string" ? responseText.trim() : ""; + if (approved) { + // Switch out of plan mode and send implementation steer + managed.session.permissionMode = "edit"; + applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + await sendMessage({ + sessionId, + text: "The user approved the plan. Please proceed with implementation.", + }); + } else { + await sendMessage({ + sessionId, + text: feedback.length > 0 + ? `The user rejected the plan with feedback: "${feedback}". Please revise.` + : "The user rejected the plan. Please revise your approach.", + }); + } + return; + } + if (pending.kind === "permissions") { const approved = decision === "accept" || decision === "accept_for_session"; managed.runtime.sendResponse(pending.requestId, { @@ -7952,6 +8388,7 @@ export function createAgentChatService(args: { if (!pending) { throw new Error(`No pending approval found for item '${itemId}'.`); } + const approved = decision === "accept" || decision === "accept_for_session"; if (decision === "accept_for_session" && pending.category !== "askUser") { managed.runtime.approvalOverrides.add(pending.category); if (pending.category === "bash") { @@ -7962,6 +8399,11 @@ export function createAgentChatService(args: { managed.session.unifiedPermissionMode = "edit"; } } + if (approved && pending.category === "exitPlanMode") { + managed.runtime.permissionMode = "edit"; + managed.session.unifiedPermissionMode = "edit"; + } + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.runtime.pendingApprovals.delete(itemId); pending.resolve({ decision, answers, responseText }); return; @@ -8075,11 +8517,13 @@ export function createAgentChatService(args: { title, modelId, reasoningEffort, + interactionMode, claudePermissionMode, codexApprovalPolicy, codexSandbox, codexConfigSource, unifiedPermissionMode, + permissionMode, computerUse, }: AgentChatUpdateSessionArgs): Promise => { const managed = ensureManagedSession(sessionId); @@ -8087,8 +8531,6 @@ export function createAgentChatService(args: { const isIdentitySession = Boolean(managed.session.identityKey); const hasConversation = managed.recentConversationEntries.length > 0 || readTranscriptConversationEntries(managed).length > 0; let resetRuntimeForComputerUse = false; - let claudeNativeSettingsChanged = false; - let codexThreadSettingsChanged = false; if (modelId !== undefined) { const nextModelId = String(modelId ?? "").trim(); @@ -8102,10 +8544,7 @@ export function createAgentChatService(args: { } const nextProvider: AgentChatProvider = resolveProviderGroupForModel(descriptor); - const nextModel = getRuntimeModelRefForDescriptor( - descriptor, - isModelProviderGroup(nextProvider) ? nextProvider : undefined, - ); + const nextModel = descriptor.isCliWrapped ? descriptor.shortId : descriptor.id; const previousModelId = managed.session.modelId ?? resolveModelIdFromStoredValue(managed.session.model, managed.session.provider) ?? managed.session.model; @@ -8150,6 +8589,13 @@ export function createAgentChatService(args: { resumeCommand: resumeCommandForProvider(nextProvider, sessionId) }); + if (isIdentitySession) { + managed.session.permissionMode = normalizeIdentityPermissionMode( + managed.session.permissionMode, + nextProvider, + ); + applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); + } normalizeSessionNativePermissionControls(managed.session, chatConfig); // Apply reasoningEffort BEFORE pre-warming so the V2 session is created @@ -8197,23 +8643,34 @@ export function createAgentChatService(args: { } } + if (permissionMode !== undefined) { + managed.session.permissionMode = isIdentitySession + ? normalizeIdentityPermissionMode(permissionMode, managed.session.provider) + : permissionMode; + applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); + } + + if (interactionMode !== undefined) { + managed.session.interactionMode = interactionMode; + } + if (claudePermissionMode !== undefined) { - claudeNativeSettingsChanged = managed.session.claudePermissionMode !== claudePermissionMode; - managed.session.claudePermissionMode = claudePermissionMode; + if (claudePermissionMode === "plan") { + managed.session.interactionMode = "plan"; + } else { + managed.session.claudePermissionMode = claudePermissionMode; + } } if (codexApprovalPolicy !== undefined) { - codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexApprovalPolicy !== codexApprovalPolicy; managed.session.codexApprovalPolicy = codexApprovalPolicy; } if (codexSandbox !== undefined) { - codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexSandbox !== codexSandbox; managed.session.codexSandbox = codexSandbox; } if (codexConfigSource !== undefined) { - codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexConfigSource !== codexConfigSource; managed.session.codexConfigSource = codexConfigSource; } @@ -8222,7 +8679,9 @@ export function createAgentChatService(args: { } if ( - claudePermissionMode !== undefined + permissionMode !== undefined + || interactionMode !== undefined + || claudePermissionMode !== undefined || codexApprovalPolicy !== undefined || codexSandbox !== undefined || codexConfigSource !== undefined @@ -8235,61 +8694,12 @@ export function createAgentChatService(args: { chatConfig.unifiedPermissionMode, ); } - } - - if (claudeNativeSettingsChanged && managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) { - if (managed.runtime.busy) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Interrupting the current Claude turn to apply permissions.", - detail: `Claude permission mode is now ${managed.session.claudePermissionMode ?? "default"}.`, - }); - managed.runtime.interrupted = true; - cancelClaudeWarmup(managed, managed.runtime, "session_reset"); - const streamGen = managed.runtime.v2StreamGen; - if (streamGen && typeof streamGen.return === "function") { - try { - await streamGen.return(undefined as never); - } catch { - // ignore interrupt errors - } - } - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - } else { - cancelClaudeWarmup(managed, managed.runtime, "session_reset"); - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - } - managed.runtime.v2Session = null; - managed.runtime.v2StreamGen = null; - managed.runtime.v2WarmupDone = null; - managed.runtime.pendingSessionReset = false; - } - - if (codexThreadSettingsChanged && managed.runtime?.kind === "codex") { - if (managed.runtime.activeTurnId && managed.session.threadId) { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Interrupting the current Codex turn to apply settings.", - detail: "ADE will rebind this thread with the new approval, sandbox, or config source before the next message.", - }); - try { - await managed.runtime.request("turn/interrupt", { - threadId: managed.session.threadId, - turnId: managed.runtime.activeTurnId, - }); - } catch (error) { - logger.warn("agent_chat.codex_interrupt_for_settings_failed", { - sessionId, - threadId: managed.session.threadId, - turnId: managed.runtime.activeTurnId, - error: error instanceof Error ? error.message : String(error), - }); - managed.runtime.pendingThreadRebind = true; + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && !managed.runtime.busy) { + const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); + if (typeof managed.runtime.v2Session.setPermissionMode === "function") { + await managed.runtime.v2Session.setPermissionMode(turnPermissionMode); } } - managed.runtime.threadResumed = false; } if (computerUse !== undefined) { @@ -8352,7 +8762,7 @@ export function createAgentChatService(args: { // picks up the correct model for warmup. managed.session.provider = "claude"; managed.session.modelId = descriptor.id; - managed.session.model = getRuntimeModelRefForDescriptor(descriptor, "claude"); + managed.session.model = descriptor.shortId; // Ensure a Claude runtime exists and kick off pre-warming ensureClaudeSessionRuntime(managed); @@ -8565,16 +8975,6 @@ export function createAgentChatService(args: { }, setComputerUseArtifactBrokerService(svc: ComputerUseArtifactBrokerService) { computerUseArtifactBrokerRef = svc; - // Detach the old observer so its session-tracking state is released. - // Clear all active sessions before dropping the reference so any - // in-flight de-duplication sets are freed eagerly rather than waiting - // for GC. - if (proofObserver) { - for (const sessionId of managedSessions.keys()) { - proofObserver.clearSession(sessionId); - } - proofObserver = null; - } proofObserver = createProofObserver({ broker: svc }); }, }; diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts index 444cdd5fd..802694b23 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts @@ -3,143 +3,438 @@ import { appendBufferedAssistantText, canAppendBufferedAssistantText, shouldFlushBufferedAssistantTextForEvent, + type BufferedAssistantText, } from "./chatTextBatching"; describe("chatTextBatching", () => { - it("appends adjacent text deltas for the same turn and item", () => { - const buffered = appendBufferedAssistantText(null, { - type: "text", - text: "Hello", - turnId: "turn-1", - itemId: "item-1", - }); - - expect(canAppendBufferedAssistantText(buffered, { - type: "text", - text: " world", - turnId: "turn-1", - itemId: "item-1", - })).toBe(true); - - expect(appendBufferedAssistantText(buffered, { - type: "text", - text: " world", - turnId: "turn-1", - itemId: "item-1", - })).toMatchObject({ - text: "Hello world", - turnId: "turn-1", - itemId: "item-1", + // ── canAppendBufferedAssistantText ──────────────────────────────── + + describe("canAppendBufferedAssistantText", () => { + it("returns false when buffered is null", () => { + expect(canAppendBufferedAssistantText(null, { + type: "text", + text: "hello", + turnId: "turn-1", + itemId: "item-1", + })).toBe(false); }); - }); - it("stops batching when the text identity changes", () => { - const buffered = appendBufferedAssistantText(null, { - type: "text", - text: "Hello", - turnId: "turn-1", - itemId: "item-1", - }); - - expect(canAppendBufferedAssistantText(buffered, { - type: "text", - text: "Other", - turnId: "turn-2", - itemId: "item-1", - })).toBe(false); - - expect(canAppendBufferedAssistantText(buffered, { - type: "text", - text: "Other", - turnId: "turn-1", - itemId: "item-2", - })).toBe(false); - }); + it("appends adjacent text deltas for the same turn and item", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); - it("flushes buffered text on structural chat events", () => { - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "tool_call", - tool: "functions.exec_command", - args: { cmd: "pwd" }, - itemId: "tool-1", - turnId: "turn-1", - })).toBe(true); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "command", - command: "pwd", - cwd: "/tmp", - output: "", - itemId: "cmd-1", - turnId: "turn-1", - status: "running", - })).toBe(true); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "approval_request", - itemId: "approval-1", - kind: "command", - description: "Run shell command", - turnId: "turn-1", - })).toBe(true); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "done", - turnId: "turn-1", - status: "completed", - })).toBe(true); - }); + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + turnId: "turn-1", + itemId: "item-1", + })).toBe(true); + }); + + it("stops batching when the turn changes", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: "Other", + turnId: "turn-2", + itemId: "item-1", + })).toBe(false); + }); + + it("stops batching when the item changes", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: "Other", + turnId: "turn-1", + itemId: "item-2", + })).toBe(false); + }); + + it("keeps batching when the logical message id stays stable across scope changes", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + messageId: "assistant-message-1", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + messageId: "assistant-message-1", + turnId: "turn-1", + })).toBe(true); + }); + + it("stops batching when messageId diverges", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + messageId: "msg-1", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + messageId: "msg-2", + turnId: "turn-1", + itemId: "item-1", + })).toBe(false); + }); + + it("does not collapse anonymous text chunks that lack identity", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + })).toBe(false); + }); + + it("allows batching with only turnId (no itemId) on both sides", () => { + const buffered: BufferedAssistantText = { + text: "Hello", + turnId: "turn-1", + }; - it("does not collapse anonymous text chunks that lack identity", () => { - const buffered = appendBufferedAssistantText(null, { - type: "text", - text: "Hello", + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + turnId: "turn-1", + })).toBe(true); }); - expect(canAppendBufferedAssistantText(buffered, { - type: "text", - text: " world", - })).toBe(false); + it("handles mismatched messageId: one has messageId and the other does not, same turnId and itemId", () => { + const buffered: BufferedAssistantText = { + text: "Hello", + messageId: "msg-1", + turnId: "turn-1", + itemId: "item-1", + }; + + // Event has no messageId but matching turnId+itemId + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " more", + turnId: "turn-1", + itemId: "item-1", + })).toBe(true); + }); + + it("returns false when one has messageId, other does not, and turnIds differ", () => { + const buffered: BufferedAssistantText = { + text: "Hello", + messageId: "msg-1", + turnId: "turn-1", + }; + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " more", + turnId: "turn-2", + })).toBe(false); + }); + + it("handles whitespace-only messageId as empty", () => { + const buffered: BufferedAssistantText = { + text: "Hello", + messageId: " ", + turnId: "turn-1", + itemId: "item-1", + }; + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + messageId: " ", + turnId: "turn-1", + itemId: "item-1", + })).toBe(true); + }); }); - it("flushes buffered text on discrete UI card events", () => { - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "todo_update", - todos: [], - turnId: "turn-1", - } as any)).toBe(true); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "subagent_started", - taskId: "task-1", - turnId: "turn-1", - } as any)).toBe(true); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "web_search", - query: "test", - turnId: "turn-1", - } as any)).toBe(true); + // ── appendBufferedAssistantText ────────────────────────────────── + + describe("appendBufferedAssistantText", () => { + it("creates a new buffer from null with all event fields", () => { + const result = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + messageId: "msg-1", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(result).toEqual({ + text: "Hello", + messageId: "msg-1", + turnId: "turn-1", + itemId: "item-1", + }); + }); + + it("creates a new buffer from null with minimal event", () => { + const result = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + }); + + expect(result).toEqual({ + text: "Hello", + }); + }); + + it("concatenates text when appending to compatible buffer", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + const result = appendBufferedAssistantText(buffered, { + type: "text", + text: " world", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(result).toMatchObject({ + text: "Hello world", + turnId: "turn-1", + itemId: "item-1", + }); + }); + + it("replaces buffer when identity changes", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + const result = appendBufferedAssistantText(buffered, { + type: "text", + text: "New", + turnId: "turn-2", + itemId: "item-2", + }); + + expect(result).toEqual({ + text: "New", + turnId: "turn-2", + itemId: "item-2", + }); + }); + + it("accumulates multiple appends", () => { + let buf = appendBufferedAssistantText(null, { + type: "text", + text: "A", + turnId: "t", + itemId: "i", + }); + buf = appendBufferedAssistantText(buf, { + type: "text", + text: "B", + turnId: "t", + itemId: "i", + }); + buf = appendBufferedAssistantText(buf, { + type: "text", + text: "C", + turnId: "t", + itemId: "i", + }); + + expect(buf.text).toBe("ABC"); + }); + + it("preserves original buffer identity on append (does not mutate)", () => { + const original = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + const appended = appendBufferedAssistantText(original, { + type: "text", + text: " world", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(original.text).toBe("Hello"); + expect(appended.text).toBe("Hello world"); + }); + + it("does not carry messageId when event has none", () => { + const result = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + }); + + expect(result.messageId).toBeUndefined(); + }); }); - it("keeps buffered text live across lightweight progress events", () => { - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "activity", - activity: "thinking", - detail: "Reasoning", - turnId: "turn-1", - })).toBe(false); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "reasoning", - text: "Thinking through it", - turnId: "turn-1", - })).toBe(false); - - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "plan_text", - text: "- step one", - turnId: "turn-1", - })).toBe(false); + // ── shouldFlushBufferedAssistantTextForEvent ───────────────────── + + describe("shouldFlushBufferedAssistantTextForEvent", () => { + it("does not flush for text events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "text", + text: "streaming...", + turnId: "turn-1", + })).toBe(false); + }); + + it("does not flush for reasoning events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "reasoning", + text: "Thinking through it", + turnId: "turn-1", + })).toBe(false); + }); + + it("does not flush for activity events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "activity", + activity: "thinking", + detail: "Reasoning", + turnId: "turn-1", + })).toBe(false); + }); + + it("does not flush for plan_text events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "plan_text", + text: "- step one", + turnId: "turn-1", + })).toBe(false); + }); + + it("flushes for tool_call events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "tool_call", + tool: "functions.exec_command", + args: { cmd: "pwd" }, + itemId: "tool-1", + turnId: "turn-1", + })).toBe(true); + }); + + it("flushes for tool_result events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "tool_result", + tool: "functions.exec_command", + result: { output: "test" }, + itemId: "tool-1", + turnId: "turn-1", + })).toBe(true); + }); + + it("flushes for command events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "command", + command: "pwd", + cwd: "/tmp", + output: "", + itemId: "cmd-1", + turnId: "turn-1", + status: "running", + })).toBe(true); + }); + + it("flushes for file_change events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "file_change", + path: "src/foo.ts", + diff: "+line", + kind: "modify", + itemId: "fc-1", + turnId: "turn-1", + })).toBe(true); + }); + + it("flushes for approval_request events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "approval_request", + itemId: "approval-1", + kind: "command", + description: "Run shell command", + turnId: "turn-1", + })).toBe(true); + }); + + it("flushes for done events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "done", + turnId: "turn-1", + status: "completed", + })).toBe(true); + }); + + it("flushes for user_message events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "user_message", + text: "Hello", + turnId: "turn-1", + })).toBe(true); + }); + + it("flushes buffered text on discrete UI card events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "todo_update", + todos: [], + turnId: "turn-1", + } as any)).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "subagent_started", + taskId: "task-1", + turnId: "turn-1", + } as any)).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "web_search", + query: "test", + turnId: "turn-1", + } as any)).toBe(true); + }); + + it("flushes for error events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "error", + message: "Something failed", + turnId: "turn-1", + } as any)).toBe(true); + }); }); }); diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.ts b/apps/desktop/src/main/services/chat/chatTextBatching.ts index 047880e92..814243ca3 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.ts @@ -2,6 +2,7 @@ import type { AgentChatEvent } from "../../../shared/types"; export type BufferedAssistantText = { text: string; + messageId?: string; turnId?: string; itemId?: string; }; @@ -11,6 +12,21 @@ export function canAppendBufferedAssistantText( event: Extract, ): boolean { if (!buffered) return false; + const bufferedMessageId = buffered.messageId?.trim() || null; + const eventMessageId = event.messageId?.trim() || null; + if (bufferedMessageId || eventMessageId) { + if (bufferedMessageId && eventMessageId) { + return bufferedMessageId === eventMessageId; + } + const bufferedTurnId = buffered.turnId ?? null; + const eventTurnId = event.turnId ?? null; + if (bufferedTurnId && eventTurnId && bufferedTurnId === eventTurnId) { + const bufferedItemId = buffered.itemId ?? null; + const eventItemId = event.itemId ?? null; + return !bufferedItemId || !eventItemId || bufferedItemId === eventItemId; + } + return false; + } // Don't collapse anonymous chunks that lack any identity if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return false; return (buffered.turnId ?? null) === (event.turnId ?? null) @@ -30,6 +46,7 @@ export function appendBufferedAssistantText( return { text: event.text, + ...(event.messageId ? { messageId: event.messageId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: event.itemId } : {}), }; diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 84715c742..4a05dce27 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -890,7 +890,7 @@ export function createCtoStateService(args: CtoStateServiceArgs) { const syncDerivedMemoryDocs = (snapshot = getSnapshot(8)): void => { const longTermDoc = renderGeneratedMemoryDoc( "CTO Memory", - "Internal ADE-generated long-term CTO memory. This mirrors the always-on brief layer plus promoted durable project memory.", + "Internal ADE-generated long-term CTO memory. This mirrors the persistent continuity brief plus promoted durable project memory.", buildLongTermMemoryLines(snapshot), ); const currentContextDoc = renderGeneratedMemoryDoc( diff --git a/apps/desktop/src/main/services/cto/flowPolicyService.test.ts b/apps/desktop/src/main/services/cto/flowPolicyService.test.ts index 8ea0c381b..9c7beb1de 100644 --- a/apps/desktop/src/main/services/cto/flowPolicyService.test.ts +++ b/apps/desktop/src/main/services/cto/flowPolicyService.test.ts @@ -48,6 +48,8 @@ describe("flowPolicyService", () => { const bootstrapped = service.getPolicy(); expect(bootstrapped.workflows.length).toBeGreaterThan(0); expect(bootstrapped.migration?.needsSave).toBe(true); + expect(bootstrapped.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(bootstrapped.intake.terminalStateTypes).toEqual(["completed", "canceled"]); const toSave: LinearWorkflowConfig = { ...bootstrapped, @@ -55,10 +57,17 @@ describe("flowPolicyService", () => { ...workflow, priority: 200 - index, })), + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted"], + terminalStateTypes: ["completed", "canceled"], + }, }; const saved = service.savePolicy(toSave, "user-a"); expect(saved.source).toBe("repo"); + expect(saved.intake.projectSlugs).toEqual(["acme-platform"]); + expect(saved.intake.activeStateTypes).toEqual(["backlog", "unstarted"]); expect(fs.readdirSync(path.join(fixture.root, ".ade", "workflows", "linear")).some((entry) => entry.endsWith(".yaml"))).toBe(true); const revisions = service.listRevisions(10); @@ -86,6 +95,11 @@ describe("flowPolicyService", () => { const validation = service.validatePolicy({ version: 1, source: "generated", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows: [ { diff --git a/apps/desktop/src/main/services/cto/flowPolicyService.ts b/apps/desktop/src/main/services/cto/flowPolicyService.ts index 44c3c2b66..0083e441d 100644 --- a/apps/desktop/src/main/services/cto/flowPolicyService.ts +++ b/apps/desktop/src/main/services/cto/flowPolicyService.ts @@ -4,6 +4,7 @@ import type { LinearSyncConfig, LinearWorkflowConfig, LinearWorkflowDefinition, + LinearWorkflowIntake, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; import { nowIso, safeJsonParse } from "../shared/utils"; @@ -16,6 +17,10 @@ type ProjectConfigServiceLike = { const DEFAULT_POLICY: LinearWorkflowConfig = { version: 1, source: "generated", + intake: { + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"], @@ -29,15 +34,150 @@ const DEFAULT_POLICY: LinearWorkflowConfig = { legacyConfig: null, }; -function clone(value: T): T { - return structuredClone(value); +function uniqueStrings(values: Array | null | undefined): string[] { + return Array.from( + new Set( + (values ?? []) + .map((entry) => entry?.trim() ?? "") + .filter((entry) => entry.length > 0) + ) + ); +} + +function normalizeIntake( + input: LinearWorkflowIntake | null | undefined, + workflows: LinearWorkflowDefinition[], + legacy: LinearSyncConfig | null | undefined, +): LinearWorkflowIntake { + const workflowProjectSlugs = workflows.flatMap((workflow) => workflow.triggers.projectSlugs ?? []); + const legacyProjectSlugs = legacy?.projects?.map((entry) => entry.slug) ?? []; + const projectSlugs = uniqueStrings([ + ...(input?.projectSlugs ?? []), + ...workflowProjectSlugs, + ...legacyProjectSlugs, + ]); + return { + ...(projectSlugs.length ? { projectSlugs } : {}), + activeStateTypes: uniqueStrings(input?.activeStateTypes ?? DEFAULT_POLICY.intake.activeStateTypes), + terminalStateTypes: uniqueStrings(input?.terminalStateTypes ?? DEFAULT_POLICY.intake.terminalStateTypes), + }; +} + +function normalizeTarget(target: LinearWorkflowDefinition["target"]): LinearWorkflowDefinition["target"] { + return { + ...target, + ...(target.workerSelector ? { workerSelector: structuredClone(target.workerSelector) } : {}), + ...(target.prStrategy ? { prStrategy: structuredClone(target.prStrategy) } : {}), + ...(target.downstreamTarget ? { downstreamTarget: normalizeTarget(target.downstreamTarget) } : {}), + }; } function normalizePolicy(input?: LinearWorkflowConfig | null): LinearWorkflowConfig { const source = input ?? DEFAULT_POLICY; + const workflows = (source.workflows ?? []) + .map((entry) => { + const target = entry.target ?? ({} as LinearWorkflowDefinition["target"]); + const triggers = entry.triggers ?? ({} as LinearWorkflowDefinition["triggers"]); + return { + ...entry, + id: entry.id?.trim() || "", + name: entry.name?.trim() || "", + source: entry.source === "repo" ? "repo" : "generated", + priority: Number.isFinite(Number(entry.priority)) ? Math.floor(Number(entry.priority)) : 100, + enabled: entry.enabled !== false, + target: normalizeTarget(target), + triggers: { + assignees: uniqueStrings(triggers.assignees), + labels: uniqueStrings(triggers.labels), + projectSlugs: uniqueStrings(triggers.projectSlugs), + teamKeys: uniqueStrings(triggers.teamKeys), + priority: uniqueStrings(triggers.priority) as LinearWorkflowDefinition["triggers"]["priority"], + stateTransitions: (triggers.stateTransitions ?? []) + .map((transition) => ({ + ...(uniqueStrings(transition?.from).length ? { from: uniqueStrings(transition.from) } : {}), + ...(uniqueStrings(transition?.to).length ? { to: uniqueStrings(transition.to) } : {}), + })) + .filter((transition) => (transition.to?.length ?? 0) > 0), + owner: uniqueStrings(triggers.owner), + creator: uniqueStrings(triggers.creator), + metadataTags: uniqueStrings(triggers.metadataTags), + }, + ...(entry.routing + ? { + routing: { + ...(uniqueStrings(entry.routing.metadataTags).length + ? { metadataTags: uniqueStrings(entry.routing.metadataTags) } + : {}), + ...(entry.routing.watchOnly === true ? { watchOnly: true } : {}), + }, + } + : {}), + steps: (entry.steps ?? []).map((step, index) => ({ + ...step, + id: step.id?.trim() || `step-${index + 1}`, + })), + ...(entry.closeout + ? { + closeout: { + ...entry.closeout, + ...(uniqueStrings(entry.closeout.applyLabels).length + ? { applyLabels: uniqueStrings(entry.closeout.applyLabels) } + : {}), + ...(uniqueStrings(entry.closeout.labels).length + ? { labels: uniqueStrings(entry.closeout.labels) } + : {}), + }, + } + : {}), + ...(entry.humanReview + ? { + humanReview: { + ...entry.humanReview, + ...(uniqueStrings(entry.humanReview.reviewers).length + ? { reviewers: uniqueStrings(entry.humanReview.reviewers) } + : {}), + }, + } + : {}), + ...(entry.retry + ? { + retry: { + ...entry.retry, + ...(Number.isFinite(Number(entry.retry.maxAttempts)) + ? { maxAttempts: Math.max(0, Math.floor(Number(entry.retry.maxAttempts))) } + : {}), + ...(Number.isFinite(Number(entry.retry.baseDelaySec)) + ? { baseDelaySec: Math.max(5, Math.floor(Number(entry.retry.baseDelaySec))) } + : {}), + ...(Number.isFinite(Number(entry.retry.backoffSeconds)) + ? { backoffSeconds: Math.max(5, Math.floor(Number(entry.retry.backoffSeconds))) } + : {}), + }, + } + : {}), + ...(entry.concurrency + ? { + concurrency: { + ...entry.concurrency, + ...(Number.isFinite(Number(entry.concurrency.maxActiveRuns)) + ? { maxActiveRuns: Math.max(1, Math.floor(Number(entry.concurrency.maxActiveRuns))) } + : {}), + ...(Number.isFinite(Number(entry.concurrency.perIssue)) + ? { perIssue: Math.max(1, Math.floor(Number(entry.concurrency.perIssue))) } + : {}), + ...(typeof entry.concurrency.dedupeByIssue === "boolean" + ? { dedupeByIssue: entry.concurrency.dedupeByIssue } + : {}), + }, + } + : {}), + }; + }) + .sort((left, right) => right.priority - left.priority || left.name.localeCompare(right.name)); return { version: 1, source: source.source === "repo" ? "repo" : "generated", + intake: normalizeIntake(source.intake, workflows, source.legacyConfig), settings: { ...(typeof source.settings?.ctoLinearAssigneeId === "string" ? { ctoLinearAssigneeId: source.settings.ctoLinearAssigneeId } : {}), ctoLinearAssigneeName: source.settings?.ctoLinearAssigneeName?.trim() || "CTO", @@ -45,19 +185,7 @@ function normalizePolicy(input?: LinearWorkflowConfig | null): LinearWorkflowCon .map((entry) => entry.trim()) .filter(Boolean), }, - workflows: (source.workflows ?? []) - .filter((entry) => Boolean(entry?.id) && Boolean(entry?.name)) - .map((entry) => ({ - ...entry, - source: entry.source === "repo" ? "repo" : "generated", - priority: Number.isFinite(Number(entry.priority)) ? Math.floor(Number(entry.priority)) : 100, - enabled: entry.enabled !== false, - steps: (entry.steps ?? []).map((step, index) => ({ - ...step, - id: step.id?.trim() || `step-${index + 1}`, - })), - })) - .sort((left, right) => right.priority - left.priority || left.name.localeCompare(right.name)), + workflows, files: Array.isArray(source.files) ? source.files : [], migration: source.migration ? { @@ -142,7 +270,7 @@ export function createFlowPolicyService(args: { id: randomUUID(), actor, createdAt: nowIso(), - policy: clone(policy), + policy: structuredClone(policy), }; args.db.run( ` @@ -173,6 +301,12 @@ export function createFlowPolicyService(args: { const validatePolicy = (policy: LinearWorkflowConfig): { ok: boolean; issues: string[] } => { const issues: string[] = []; const normalized = normalizePolicy(policy); + if (!normalized.intake.activeStateTypes?.length) { + issues.push("At least one intake active state type is required."); + } + if (!normalized.intake.terminalStateTypes?.length) { + issues.push("At least one intake terminal state type is required."); + } if (!normalized.workflows.length) { issues.push("At least one workflow is required."); } @@ -270,7 +404,7 @@ export function createFlowPolicyService(args: { validatePolicy, normalizePolicy, diffPolicyPaths, - defaults: clone(DEFAULT_POLICY), + defaults: structuredClone(DEFAULT_POLICY), }; } diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts index 6a8a9d037..59d1693dc 100644 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts +++ b/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts @@ -160,7 +160,7 @@ describe("linearCloseoutService", () => { expect(issueTracker.updateIssueState).toHaveBeenCalledWith(issueFixture.id, "state-done"); expect(issueTracker.addLabel).toHaveBeenCalledWith(issueFixture.id, "ade"); expect(issueTracker.createComment).toHaveBeenCalledWith(issueFixture.id, "Closeout applied."); - expect(publishMissionCloseout).toHaveBeenCalledWith({ + expect(publishMissionCloseout).toHaveBeenCalledWith(expect.objectContaining({ issue: issueFixture, missionId: "mission-1", status: "completed", @@ -177,7 +177,8 @@ describe("linearCloseoutService", () => { "https://example.com/browser-trace.zip", ], artifactMode: "links", - }); + commentTemplate: null, + })); }); it("publishes non-mission PR links and broker artifacts to the generic Linear closeout", async () => { @@ -234,7 +235,7 @@ describe("linearCloseoutService", () => { summary: "Worker handoff wrapped with linked proof.", }); - expect(publishWorkflowCloseout).toHaveBeenCalledWith({ + expect(publishWorkflowCloseout).toHaveBeenCalledWith(expect.objectContaining({ issue: issueFixture, status: "completed", summary: "Worker handoff wrapped with linked proof.", @@ -253,6 +254,7 @@ describe("linearCloseoutService", () => { "https://example.com/pr-proof.json", ], artifactMode: "links", - }); + commentTemplate: null, + })); }); }); diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.ts index 2d854d71b..40d76defa 100644 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.ts +++ b/apps/desktop/src/main/services/cto/linearCloseoutService.ts @@ -11,6 +11,7 @@ import type { createMissionService } from "../missions/missionService"; import type { createOrchestratorService } from "../orchestrator/orchestratorService"; import type { createPrService } from "../prs/prService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; +import { renderTemplateString } from "../shared/utils"; function resolveStateId(states: Array<{ id: string; name: string; type: string }>, stateKey: string | undefined): string | null { if (!stateKey) return null; @@ -151,6 +152,43 @@ export function createLinearCloseoutService(args: { }): Promise => { const states = await args.issueTracker.fetchWorkflowStates(input.issue.teamKey); const closeout = input.workflow.closeout; + const prSummaries = args.prService.listAll(); + const linkedPr = input.run.linkedPrId + ? prSummaries.find((entry) => entry.id === input.run.linkedPrId) ?? null + : null; + + const closeoutArtifacts = collectCloseoutArtifacts(input); + + const templateValues: Record = { + issue: input.issue, + workflow: { + id: input.workflow.id, + name: input.workflow.name, + }, + run: input.run, + target: { + type: input.run.executionContext?.activeTargetType ?? input.workflow.target.type, + id: + input.run.linkedSessionId + ?? input.run.linkedWorkerRunId + ?? input.run.linkedMissionId + ?? input.run.linkedPrId + ?? input.run.executionLaneId + ?? null, + }, + pr: { + id: input.run.linkedPrId ?? null, + url: linkedPr?.githubUrl ?? closeoutArtifacts.prLinks[0] ?? null, + links: closeoutArtifacts.prLinks, + }, + review: { + state: input.run.reviewState, + readyReason: input.run.reviewReadyReason, + note: input.run.latestReviewNote, + }, + note: input.summary, + waitingFor: input.run.executionContext?.waitingFor ?? null, + }; const desiredState = input.outcome === "completed" ? resolveStateId(states, closeout?.successState) @@ -159,16 +197,17 @@ export function createLinearCloseoutService(args: { await args.issueTracker.updateIssueState(input.issue.id, desiredState); } - for (const label of closeout?.applyLabels ?? []) { + for (const label of uniqueStrings([...(closeout?.applyLabels ?? []), ...(closeout?.labels ?? [])])) { await args.issueTracker.addLabel(input.issue.id, label); } - const comment = input.outcome === "completed" ? closeout?.successComment : closeout?.failureComment; + const renderedTemplate = closeout?.commentTemplate?.trim() + ? renderTemplateString(closeout.commentTemplate, templateValues).trim() + : ""; + const comment = renderedTemplate || (input.outcome === "completed" ? closeout?.successComment : closeout?.failureComment); if (comment?.trim()) { await args.issueTracker.createComment(input.issue.id, comment.trim()); } - - const closeoutArtifacts = collectCloseoutArtifacts(input); if (input.workflow.target.type === "mission" && input.run.linkedMissionId) { await args.outboundService.publishMissionCloseout({ issue: input.issue, @@ -178,6 +217,14 @@ export function createLinearCloseoutService(args: { prLinks: closeoutArtifacts.prLinks, artifactPaths: closeoutArtifacts.artifactPaths, artifactMode: closeout?.artifactMode ?? "links", + commentTemplate: closeout?.commentTemplate ?? null, + templateValues: { + ...templateValues, + pr: { + ...(templateValues.pr as Record), + links: closeoutArtifacts.prLinks, + }, + }, }); return; } @@ -197,6 +244,14 @@ export function createLinearCloseoutService(args: { prLinks: closeoutArtifacts.prLinks, artifactPaths: closeoutArtifacts.artifactPaths, artifactMode: closeout?.artifactMode ?? "links", + commentTemplate: closeout?.commentTemplate ?? null, + templateValues: { + ...templateValues, + pr: { + ...(templateValues.pr as Record), + links: closeoutArtifacts.prLinks, + }, + }, }); }; diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts index 3f9669c06..113754c95 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts @@ -36,10 +36,17 @@ const issueFixture: NormalizedLinearIssue = { raw: {}, }; +const intake = { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], +}; + function buildPolicy(targetType: "mission" | "review_gate" | "worker_run"): LinearWorkflowConfig { return { version: 1, source: "repo", + intake, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows: [ { @@ -69,6 +76,7 @@ function buildEmployeeSessionPolicy(): LinearWorkflowConfig { return { version: 1, source: "repo", + intake, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows: [ { @@ -97,6 +105,7 @@ function buildDirectCtoSessionPolicy(overrides?: Partial { sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -357,7 +529,7 @@ describe("linearDispatcherService", () => { sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "noop" })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -407,7 +579,7 @@ describe("linearDispatcherService", () => { sendMessage, listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", status: "idle" }]), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please implement the issue." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -458,7 +630,7 @@ describe("linearDispatcherService", () => { sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -481,6 +653,59 @@ describe("linearDispatcherService", () => { db.close(); }); + it("rewinds launch_target when retrying a run that is awaiting delegation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-retry-awaiting-delegation-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const employeeIssue = { + ...issueFixture, + assigneeId: "unknown-agent", + assigneeName: "Unknown Agent", + labels: ["workflow:backend"], + }; + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + const detailBeforeRetry = await dispatcher.getRunDetail(run.id, policy); + expect(detailBeforeRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); + + await dispatcher.resolveRunAction(run.id, "retry", "Try again.", policy); + + const detailAfterRetry = await dispatcher.getRunDetail(run.id, policy); + expect(detailAfterRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("pending"); + db.close(); + }); + it("launches a direct CTO employee session when the workflow targets CTO", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-cto-session-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); @@ -506,7 +731,7 @@ describe("linearDispatcherService", () => { sendMessage: vi.fn(async () => ({ id: "message-1" })), listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -564,7 +789,7 @@ describe("linearDispatcherService", () => { sendMessage: vi.fn(async () => ({ id: "message-1" })), listSessions: vi.fn(async () => sessions), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -641,6 +866,7 @@ describe("linearDispatcherService", () => { listSessions: vi.fn(async () => [{ sessionId: "session-fresh-1", laneId: "lane-2", status: "idle" }]), } as any, laneService: { + ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), create: createLane, } as any, @@ -696,7 +922,7 @@ describe("linearDispatcherService", () => { missionService: { create: vi.fn(), get: vi.fn() } as any, aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, closeoutService: { applyOutcome: closeout } as any, outboundService: createOutboundServiceMocks(), @@ -725,6 +951,179 @@ describe("linearDispatcherService", () => { db.close(); }); + it("scopes manual completion markers to the active downstream stage", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-manual-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDownstreamManualCompletionPolicy(); + const ensureIdentitySession = vi + .fn() + .mockResolvedValueOnce({ id: "session-1" }) + .mockResolvedValueOnce({ id: "session-2" }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [ + { sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }, + { sessionId: "session-2", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:01:00.000Z" }, + ]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-manual"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "complete", "Stage 1 finished.", policy); + + const afterHandoff = await dispatcher.advanceRun(run.id, policy); + expect(afterHandoff?.status).toBe("waiting_for_target"); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); + + const stillWaiting = await dispatcher.advanceRun(run.id, policy); + expect(stillWaiting?.status).toBe("waiting_for_target"); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); + expect(ensureIdentitySession).toHaveBeenCalledTimes(2); + db.close(); + }); + + it("clears incompatible CTO overrides before handing off to a worker-backed downstream target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-override-handoff-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeToWorkerHandoffPolicy(); + const triggerWakeup = vi.fn(async () => ({ runId: "worker-run-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup, + listRuns: vi.fn(() => []), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:employee-to-worker"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "complete", "Hand off to a worker.", policy, "cto"); + + const handedOff = await dispatcher.advanceRun(run.id, policy); + expect(handedOff?.status).toBe("waiting_for_target"); + expect(handedOff?.linkedWorkerRunId).toBe("worker-run-1"); + expect(dispatcher.listQueue()[0]?.employeeOverride).toBeNull(); + expect(triggerWakeup).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("preserves a launched session instead of scheduling a retry after a partial launch failure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-partial-launch-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy(); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => { + throw new Error("Chat delivery failed after session creation."); + }), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + const preserved = await dispatcher.advanceRun(run.id, policy); + + expect(preserved?.status).toBe("waiting_for_target"); + expect(preserved?.linkedSessionId).toBe("session-cto-1"); + expect(dispatcher.listQueue()[0]?.status).not.toBe("retry_wait"); + + const detail = await dispatcher.getRunDetail(run.id, policy); + expect(detail?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); + + const resumed = await dispatcher.advanceRun(run.id, policy); + expect(resumed?.status).toBe("waiting_for_target"); + expect(ensureIdentitySession).toHaveBeenCalledTimes(1); + db.close(); + }); + it("still completes delegated workflows that are configured to complete on launch", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-complete-on-launch-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); @@ -754,7 +1153,7 @@ describe("linearDispatcherService", () => { sendMessage: vi.fn(async () => ({ id: "message-1" })), listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, closeoutService: { applyOutcome: closeout } as any, outboundService: createOutboundServiceMocks(), @@ -849,6 +1248,7 @@ describe("linearDispatcherService", () => { aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { + ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), create: vi.fn(async () => ({ id: "lane-2", name: "ABC-42 fresh lane" })), } as any, @@ -879,6 +1279,66 @@ describe("linearDispatcherService", () => { db.close(); }); + it("persists downstream session ownership after handing work from a worker to an employee session", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-session-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDownstreamEmployeeSessionPolicy(); + const outboundService = createOutboundServiceMocks(); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-2" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-2", laneId: "lane-1", status: "idle", identityKey: "cto" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService, + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-session"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.advanceRun(run.id, policy); + + const queueItem = dispatcher.listQueue()[0]!; + expect(queueItem.sessionId).toBe("session-cto-2"); + expect(queueItem.sessionLabel).toBe("CTO"); + expect(queueItem.workerId).toBeNull(); + expect(queueItem.workerSlug).toBeNull(); + expect(outboundService.publishWorkflowStatus).toHaveBeenLastCalledWith(expect.objectContaining({ + delegatedOwner: "CTO", + sessionId: "session-cto-2", + })); + db.close(); + }); + it("pauses for supervisor approval and can resume after approval", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-supervisor-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); @@ -907,6 +1367,7 @@ describe("linearDispatcherService", () => { aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { + ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), create: createLane, } as any, @@ -962,6 +1423,7 @@ describe("linearDispatcherService", () => { aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { + ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), create: vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })), } as any, @@ -1016,7 +1478,7 @@ describe("linearDispatcherService", () => { missionService: { create: vi.fn(), get: vi.fn() } as any, aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, closeoutService: { applyOutcome: closeout } as any, outboundService: createOutboundServiceMocks(), @@ -1105,7 +1567,7 @@ describe("linearDispatcherService", () => { missionService: { create: vi.fn(), get: vi.fn() } as any, aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a review-ready PR." })) } as any, closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, outboundService: createOutboundServiceMocks(), @@ -1132,4 +1594,54 @@ describe("linearDispatcherService", () => { expect(dispatcher.listQueue()[0]?.prReviewStatus).toBe("approved"); db.close(); }); + + it("fails before launching a downstream PR stage when the workflow is missing wait_for_pr", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-invalid-downstream-pr-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildInvalidDownstreamPrPolicy(); + const triggerWakeup = vi + .fn() + .mockResolvedValueOnce({ runId: "worker-run-1" }) + .mockResolvedValueOnce({ runId: "worker-run-2" }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, + workerHeartbeatService: { + triggerWakeup, + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-pr"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + const retried = await dispatcher.advanceRun(run.id, policy); + expect(retried?.status).toBe("failed"); + expect(triggerWakeup).toHaveBeenCalledTimes(1); + db.close(); + }); }); diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.ts index 5da5eb3b2..a6be4542f 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.ts @@ -5,9 +5,11 @@ import type { LinearIngressEventRecord, LinearSyncQueueItem, LinearWorkflowConfig, + LinearWorkflowExecutionContext, LinearWorkflowDefinition, LinearWorkflowEventPayload, LinearWorkflowMatchResult, + LinearWorkflowRouteContext, LinearWorkflowRunDetail, LinearWorkflowRun, LinearWorkflowRunEvent, @@ -18,7 +20,7 @@ import type { NormalizedLinearIssue, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; -import { nowIso } from "../shared/utils"; +import { nowIso, safeJsonParse } from "../shared/utils"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createMissionService } from "../missions/missionService"; import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; @@ -62,6 +64,8 @@ type RunRow = { closeout_state: LinearWorkflowRun["closeoutState"]; terminal_outcome: LinearWorkflowRun["terminalOutcome"]; source_issue_snapshot_json: string; + route_context_json: string | null; + execution_context_json: string | null; last_error: string | null; created_at: string; updated_at: string; @@ -119,6 +123,8 @@ function toRun(row: RunRow): LinearWorkflowRun { closeoutState: row.closeout_state, terminalOutcome: row.terminal_outcome, sourceIssueSnapshot: JSON.parse(row.source_issue_snapshot_json), + routeContext: safeJsonParse(row.route_context_json, null), + executionContext: safeJsonParse(row.execution_context_json, null), createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -155,22 +161,10 @@ function toRunEvent(row: EventRow): LinearWorkflowRunEvent { }; } -function buildPrompt(issue: NormalizedLinearIssue): string { - return [ - `${issue.identifier}: ${issue.title}`, - "", - issue.description || "No description provided.", - "", - `Project: ${issue.projectSlug}`, - `Priority: ${issue.priorityLabel}`, - `Labels: ${issue.labels.join(", ") || "none"}`, - ].join("\n"); -} - -function describePrBehavior(workflow: LinearWorkflowDefinition): string { - const strategy = workflow.target.prStrategy; +function describePrBehavior(target: LinearWorkflowDefinition["target"]): string { + const strategy = target.prStrategy; if (!strategy) return "No PR will be created unless a later workflow step requires one."; - const timing = workflow.target.prTiming ?? "after_target_complete"; + const timing = target.prTiming ?? "after_target_complete"; if (strategy.kind === "manual") { return `Track a manually created PR (${timing === "after_start" ? "watch immediately" : "wait after delegated work"}).`; } @@ -194,6 +188,14 @@ function targetStatusAllowsTerminalSuccess(targetStatus: LinearWorkflowTargetSta return targetStatus === "completed" || targetStatus === "runtime_completed" || targetStatus === "any_terminal"; } +function getTargetStages(target: LinearWorkflowDefinition["target"]): LinearWorkflowDefinition["target"][] { + const { downstreamTarget, ...current } = target; + return [ + current as LinearWorkflowDefinition["target"], + ...(downstreamTarget ? getTargetStages(downstreamTarget) : []), + ]; +} + export function createLinearDispatcherService(args: { db: AdeDb; projectId: string; @@ -256,15 +258,6 @@ export function createLinearDispatcherService(args: { }); }; - const parsePayload = >(value: string | null): T | null => { - if (!value) return null; - try { - return JSON.parse(value) as T; - } catch { - return null; - } - }; - const getRunRow = (runId: string): RunRow | null => args.db.get( ` @@ -284,7 +277,7 @@ export function createLinearDispatcherService(args: { select * from linear_workflow_runs where project_id = ? - and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'awaiting_delegation', 'retry_wait') + and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'awaiting_delegation', 'awaiting_lane_choice', 'retry_wait') order by datetime(created_at) asc `, [args.projectId] @@ -297,7 +290,7 @@ export function createLinearDispatcherService(args: { select count(*) as total from linear_workflow_runs where project_id = ? - and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'awaiting_delegation', 'retry_wait') + and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'awaiting_delegation', 'awaiting_lane_choice', 'retry_wait') limit 1 `, [args.projectId] @@ -347,6 +340,8 @@ export function createLinearDispatcherService(args: { retryAfter: string | null; closeoutState: LinearWorkflowRun["closeoutState"]; terminalOutcome: LinearWorkflowRun["terminalOutcome"]; + routeContext: LinearWorkflowRouteContext | null; + executionContext: LinearWorkflowExecutionContext | null; lastError: string | null; }>): void => { const existing = getRunRow(runId); @@ -373,6 +368,8 @@ export function createLinearDispatcherService(args: { retry_after = ?, closeout_state = ?, terminal_outcome = ?, + route_context_json = ?, + execution_context_json = ?, last_error = ?, updated_at = ? where id = ? @@ -398,6 +395,8 @@ export function createLinearDispatcherService(args: { patch.retryAfter === undefined ? existing.retry_after : patch.retryAfter, patch.closeoutState ?? existing.closeout_state, patch.terminalOutcome === undefined ? existing.terminal_outcome : patch.terminalOutcome, + patch.routeContext === undefined ? existing.route_context_json : patch.routeContext ? JSON.stringify(patch.routeContext) : null, + patch.executionContext === undefined ? existing.execution_context_json : patch.executionContext ? JSON.stringify(patch.executionContext) : null, patch.lastError === undefined ? existing.last_error : patch.lastError, nowIso(), runId, @@ -406,6 +405,58 @@ export function createLinearDispatcherService(args: { ); }; + const mergeExecutionContext = ( + runId: string, + patch: Partial> | null, + ): LinearWorkflowExecutionContext | null => { + const current = getRunRow(runId); + if (!current) return null; + const existing = safeJsonParse | null>(current.execution_context_json, null) ?? {}; + if (!patch) { + updateRun(runId, { executionContext: null }); + return null; + } + const next = { ...existing, ...patch } as LinearWorkflowExecutionContext; + updateRun(runId, { executionContext: next }); + return next; + }; + + const getActiveTargetStageIndex = (run: LinearWorkflowRun, workflow: LinearWorkflowDefinition): number => { + const requested = Number(run.executionContext?.activeStageIndex ?? 0); + const stages = getTargetStages(workflow.target); + if (!Number.isFinite(requested)) return 0; + return Math.max(0, Math.min(stages.length - 1, Math.floor(requested))); + }; + + const getActiveTarget = (run: LinearWorkflowRun, workflow: LinearWorkflowDefinition): LinearWorkflowDefinition["target"] => { + const stages = getTargetStages(workflow.target); + return stages[getActiveTargetStageIndex(run, workflow)] ?? stages[0] ?? workflow.target; + }; + + const buildTargetStageId = ( + run: LinearWorkflowRun, + workflow: LinearWorkflowDefinition, + step: LinearWorkflowStep, + ): string => { + const activeTarget = getActiveTarget(run, workflow); + return `${step.id}:${getActiveTargetStageIndex(run, workflow)}:${activeTarget.type}`; + }; + + const stripManualCompletionState = ( + payload: Record, + ): Record => { + const { + completionSource: _completionSource, + stageId: _stageId, + targetState: _targetState, + ...rest + } = payload; + return rest; + }; + + const hasDurableLaunchMarker = (run: LinearWorkflowRun): boolean => + Boolean(run.linkedMissionId || run.linkedSessionId || run.linkedWorkerRunId || run.linkedPrId); + const updateStep = (stepId: string, patch: Partial<{ status: StepRow["status"]; startedAt: string | null; @@ -445,21 +496,118 @@ export function createLinearDispatcherService(args: { label: string; }; - const resolveWorker = (workflow: LinearWorkflowDefinition): ResolvedWorkerTarget | null => { - const selector = workflow.target.workerSelector; - const workers = args.workerAgentService.listAgents({ includeDeleted: false }); + const listWorkers = () => args.workerAgentService.listAgents({ includeDeleted: false }); + + const toResolvedWorker = (worker: { + id: string; + slug: string; + adapterType: AdapterType; + name: string; + }): ResolvedWorkerTarget & { name: string } => ({ + id: worker.id, + slug: worker.slug, + adapterType: worker.adapterType, + name: worker.name, + }); + + const resolveWorkerByToken = (value: string | null | undefined): (ResolvedWorkerTarget & { name: string }) | null => { + const normalized = (value ?? "").trim().toLowerCase(); + if (!normalized) return null; + for (const worker of listWorkers()) { + const aliases = new Set( + [ + worker.id, + worker.slug, + worker.name, + ...(worker.linearIdentity?.userIds ?? []), + ...(worker.linearIdentity?.displayNames ?? []), + ...(worker.linearIdentity?.aliases ?? []), + ] + .map((entry) => (entry ?? "").trim().toLowerCase()) + .filter(Boolean), + ); + if (aliases.has(normalized)) { + return toResolvedWorker({ + id: worker.id, + slug: worker.slug, + adapterType: worker.adapterType as AdapterType, + name: worker.name, + }); + } + } + return null; + }; + + const isCtoAlias = (policy: LinearWorkflowConfig, value: string | null | undefined): boolean => { + const normalized = (value ?? "").trim().toLowerCase(); + if (!normalized) return false; + const ctoAliases = new Set( + [ + policy.settings.ctoLinearAssigneeId, + policy.settings.ctoLinearAssigneeName, + ...(policy.settings.ctoLinearAssigneeAliases ?? []), + "cto", + ] + .map((entry) => (entry ?? "").trim().toLowerCase()) + .filter(Boolean), + ); + return ctoAliases.has(normalized); + }; + + const resolveOverrideWorker = ( + policy: LinearWorkflowConfig, + override: string | null | undefined, + ): (ResolvedWorkerTarget & { name: string }) | "cto" | null => { + const trimmed = (override ?? "").trim(); + if (!trimmed) return null; + if (isCtoAlias(policy, trimmed)) return "cto"; + const normalized = trimmed.toLowerCase(); + if (normalized.startsWith("agent:")) { + const agentId = trimmed.slice("agent:".length).trim(); + const direct = agentId ? args.workerAgentService.getAgent(agentId) : null; + if (!direct) { + throw new Error(`Unknown employee override '${trimmed}'.`); + } + return toResolvedWorker({ + id: direct.id, + slug: direct.slug, + adapterType: direct.adapterType as AdapterType, + name: direct.name, + }); + } + const worker = resolveWorkerByToken(trimmed); + if (!worker) { + throw new Error(`Unknown employee override '${trimmed}'.`); + } + return worker; + }; + + const resolveWorker = ( + policy: LinearWorkflowConfig, + target: LinearWorkflowDefinition["target"], + override?: string | null, + ): (ResolvedWorkerTarget & { name: string }) | null => { + const overridden = resolveOverrideWorker(policy, override); + if (overridden === "cto") { + return null; + } + if (overridden) { + return overridden; + } + const selector = target.workerSelector; + const workers = listWorkers(); if (!selector || selector.mode === "none") return null; if (selector.mode === "id") { const match = workers.find((entry) => entry.id === selector.value); - return match ? { id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType } : null; + return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; } if (selector.mode === "slug") { const match = workers.find((entry) => entry.slug === selector.value); - return match ? { id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType } : null; + return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; } if (selector.mode === "capability") { const match = workers.find((entry) => entry.capabilities.includes(selector.value)); - return match ? { id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType } : null; + return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; } return null; }; @@ -468,26 +616,12 @@ export function createLinearDispatcherService(args: { const assigneeValues = new Set( [issue.assigneeId, issue.assigneeName] .map((value) => (value ?? "").trim().toLowerCase()) - .filter(Boolean) + .filter(Boolean), ); if (!assigneeValues.size) return null; - const workers = args.workerAgentService.listAgents({ includeDeleted: false }); - for (const worker of workers) { - const aliases = new Set( - [ - worker.id, - worker.slug, - worker.name, - ...(worker.linearIdentity?.userIds ?? []), - ...(worker.linearIdentity?.displayNames ?? []), - ...(worker.linearIdentity?.aliases ?? []), - ] - .map((value) => (value ?? "").trim().toLowerCase()) - .filter(Boolean) - ); - if ([...assigneeValues].some((value) => aliases.has(value))) { - return { id: worker.id, slug: worker.slug, adapterType: worker.adapterType as AdapterType }; - } + for (const assigneeValue of assigneeValues) { + const match = resolveWorkerByToken(assigneeValue); + if (match) return match; } return null; }; @@ -496,28 +630,34 @@ export function createLinearDispatcherService(args: { const assigneeValues = new Set( [issue.assigneeId, issue.assigneeName] .map((value) => (value ?? "").trim().toLowerCase()) - .filter(Boolean) + .filter(Boolean), ); if (!assigneeValues.size) return false; - const ctoAliases = new Set( - [ - policy.settings.ctoLinearAssigneeId, - policy.settings.ctoLinearAssigneeName, - ...(policy.settings.ctoLinearAssigneeAliases ?? []), - "cto", - ] - .map((value) => (value ?? "").trim().toLowerCase()) - .filter(Boolean) - ); - return [...assigneeValues].some((value) => ctoAliases.has(value)); + return [...assigneeValues].some((value) => isCtoAlias(policy, value)); }; const resolveEmployeeTarget = ( policy: LinearWorkflowConfig, - workflow: LinearWorkflowDefinition, - issue: NormalizedLinearIssue + target: LinearWorkflowDefinition["target"], + issue: NormalizedLinearIssue, + override?: string | null, ): ResolvedEmployeeSessionTarget | null => { - const explicitIdentity = workflow.target.employeeIdentityKey?.trim() ?? ""; + const overridden = resolveOverrideWorker(policy, override); + if (overridden === "cto") { + return { + identityKey: "cto", + worker: null, + label: "CTO", + }; + } + if (overridden) { + return { + identityKey: `agent:${overridden.id}`, + worker: overridden, + label: overridden.name, + }; + } + const explicitIdentity = target.employeeIdentityKey?.trim() ?? ""; if (explicitIdentity === "cto") { return { identityKey: "cto", @@ -543,7 +683,7 @@ export function createLinearDispatcherService(args: { label: "CTO", }; } - const worker = resolveWorkerFromAssignee(issue) ?? resolveWorker(workflow); + const worker = resolveWorkerFromAssignee(issue) ?? resolveWorker(policy, target); if (!worker) { return { identityKey: null, @@ -558,28 +698,78 @@ export function createLinearDispatcherService(args: { }; }; + const isEmployeeOverrideCompatibleWithTarget = ( + policy: LinearWorkflowConfig, + targetType: LinearWorkflowDefinition["target"]["type"], + override: string | null | undefined, + ): boolean => { + const trimmed = (override ?? "").trim(); + if (!trimmed) return true; + try { + const resolved = resolveOverrideWorker(policy, trimmed); + if (resolved === "cto") { + return targetType !== "worker_run" && targetType !== "pr_resolution"; + } + return true; + } catch { + return false; + } + }; + + const buildLaunchPayloadFromRun = ( + run: LinearWorkflowRun, + workflow: LinearWorkflowDefinition, + policy: LinearWorkflowConfig, + ): Record => { + const activeTarget = getActiveTarget(run, workflow); + const issue = run.sourceIssueSnapshot as NormalizedLinearIssue; + const payload: Record = { + activeTargetType: run.executionContext?.activeTargetType ?? activeTarget.type, + laneId: run.executionLaneId, + missionId: run.linkedMissionId, + sessionId: run.linkedSessionId, + workerRunId: run.linkedWorkerRunId, + prId: run.linkedPrId, + }; + if (run.executionContext?.workerId) payload.workerId = run.executionContext.workerId; + if (run.executionContext?.workerSlug) payload.workerSlug = run.executionContext.workerSlug; + if (run.executionContext?.sessionLabel) payload.sessionLabel = run.executionContext.sessionLabel; + if (activeTarget.type === "employee_session") { + const employeeTarget = resolveEmployeeTarget(policy, activeTarget, issue, run.executionContext?.employeeOverride ?? null); + if (employeeTarget?.identityKey) payload.sessionIdentityKey = employeeTarget.identityKey; + if (employeeTarget?.label) payload.sessionLabel = employeeTarget.label; + } + return payload; + }; + const primaryLane = async () => { + await args.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); const preferred = lanes.find((entry) => entry.laneType === "primary") ?? lanes[0]; if (!preferred) throw new Error("No lane available for employee session launch."); return preferred; }; - const buildDelegationPrompt = (run: LinearWorkflowRun, workflow: LinearWorkflowDefinition, issue: NormalizedLinearIssue): string => { + const buildDelegationPrompt = ( + run: LinearWorkflowRun, + workflow: LinearWorkflowDefinition, + target: LinearWorkflowDefinition["target"], + issue: NormalizedLinearIssue, + ): string => { const rendered = args.templateService.renderTemplate({ - templateId: workflow.target.sessionTemplate ?? workflow.target.missionTemplate ?? "default", + templateId: target.sessionTemplate ?? target.missionTemplate ?? "default", issue, route: { workflowId: workflow.id, workflowName: workflow.name, - runMode: workflow.target.runMode ?? "autopilot", + runMode: target.runMode ?? "autopilot", }, worker: { assigneeId: issue.assigneeId, assigneeName: issue.assigneeName, }, }); - const prMode = `PR behavior: ${describePrBehavior(workflow)}`; + const prMode = `PR behavior: ${describePrBehavior(target)}`; const supervisorFeedback = run.latestReviewNote?.trim() ? [ "", @@ -599,9 +789,33 @@ export function createLinearDispatcherService(args: { ].join("\n"); }; + const readDelegationContext = (run: LinearWorkflowRun, launchContext?: Record | null) => { + const source = launchContext ?? {}; + const executionContext = run.executionContext ?? null; + const workerId = + executionContext && "workerId" in executionContext + ? (typeof executionContext.workerId === "string" ? executionContext.workerId : null) + : typeof source.workerId === "string" + ? source.workerId + : null; + const workerSlug = + executionContext && "workerSlug" in executionContext + ? (typeof executionContext.workerSlug === "string" ? executionContext.workerSlug : null) + : typeof source.workerSlug === "string" + ? source.workerSlug + : null; + const sessionLabel = + executionContext && "sessionLabel" in executionContext + ? (typeof executionContext.sessionLabel === "string" ? executionContext.sessionLabel : null) + : typeof source.sessionLabel === "string" + ? source.sessionLabel + : null; + return { workerId, workerSlug, sessionLabel }; + }; + const getLaunchContext = (runId: string): Record => { const step = getStepRows(runId).find((entry) => entry.type === "launch_target"); - return parsePayload>(step?.payload_json ?? null) ?? {}; + return safeJsonParse | null>(step?.payload_json ?? null, null) ?? {}; }; const syncWorkflowWorkpad = async (input: { @@ -616,19 +830,16 @@ export function createLinearDispatcherService(args: { const issue = run.sourceIssueSnapshot as NormalizedLinearIssue; const launchContext = getLaunchContext(run.id); const currentStep = input.workflow.steps.find((entry) => entry.id === run.currentStepId) ?? null; + const delegatedContext = readDelegationContext(run, launchContext); const delegatedOwner = - typeof launchContext.sessionLabel === "string" && launchContext.sessionLabel.trim().length - ? launchContext.sessionLabel.trim() - : typeof launchContext.workerSlug === "string" && launchContext.workerSlug.trim().length - ? launchContext.workerSlug.trim() - : typeof launchContext.workerId === "string" && launchContext.workerId.trim().length - ? launchContext.workerId.trim() - : null; + [delegatedContext.sessionLabel, delegatedContext.workerSlug, delegatedContext.workerId] + .map((value) => value?.trim() ?? "") + .find((value) => value.length > 0) ?? null; await args.outboundService.publishWorkflowStatus({ issue, workflowName: run.workflowName, runId: run.id, - targetType: run.targetType, + targetType: run.executionContext?.activeTargetType ?? run.targetType, state: run.status, currentStep: currentStep?.name ?? currentStep?.id ?? null, delegatedOwner, @@ -639,8 +850,32 @@ export function createLinearDispatcherService(args: { prId: run.linkedPrId, reviewState: run.reviewState, reviewReadyReason: run.reviewReadyReason, - waitingFor: input.waitingFor, + waitingFor: input.waitingFor ?? run.executionContext?.waitingFor ?? null, note: input.note, + commentTemplate: input.workflow.closeout?.commentTemplate ?? null, + templateValues: { + workflow: { + id: input.workflow.id, + name: input.workflow.name, + }, + run, + target: { + type: run.executionContext?.activeTargetType ?? run.targetType, + id: run.linkedSessionId ?? run.linkedWorkerRunId ?? run.linkedMissionId ?? run.linkedPrId ?? run.executionLaneId ?? null, + owner: delegatedOwner, + }, + pr: { + id: run.linkedPrId, + state: run.prState, + checksStatus: run.prChecksStatus, + reviewStatus: run.prReviewStatus, + }, + review: { + state: run.reviewState, + readyReason: run.reviewReadyReason, + note: run.latestReviewNote, + }, + }, }); }; @@ -657,21 +892,86 @@ export function createLinearDispatcherService(args: { return null; }; + const updateExecutionState = ( + runId: string, + patch: Partial, + ): LinearWorkflowExecutionContext | null => mergeExecutionContext(runId, patch); + + const clearExecutionWaitState = (runId: string): LinearWorkflowExecutionContext | null => + updateExecutionState(runId, { + waitingFor: null, + stalledReason: null, + }); + + const getRetryDelaySec = (workflow: LinearWorkflowDefinition, retryCount: number): number => { + const baseDelay = workflow.retry?.backoffSeconds ?? workflow.retry?.baseDelaySec ?? 30; + const safeBase = Math.max(5, Math.floor(baseDelay)); + return safeBase * (2 ** Math.max(0, retryCount)); + }; + + const scheduleRetry = async ( + run: LinearWorkflowRun, + workflow: LinearWorkflowDefinition, + error: unknown, + context: { eventType: string; messagePrefix: string; waitingFor?: string | null }, + ): Promise => { + const errorMessage = error instanceof Error ? error.message : String(error); + const currentAttempts = run.retryCount ?? 0; + const maxAttempts = Math.max(0, workflow.retry?.maxAttempts ?? 0); + if (currentAttempts >= maxAttempts) { + appendEvent(run.id, context.eventType, "failed", `${context.messagePrefix}: ${errorMessage}`, { + retryCount: currentAttempts, + maxAttempts, + }); + await finalizeRun(run, workflow, "failed", `${context.messagePrefix}: ${errorMessage}`); + return; + } + + const retryCount = currentAttempts + 1; + const delaySec = getRetryDelaySec(workflow, currentAttempts); + const retryAfter = new Date(Date.now() + delaySec * 1000).toISOString(); + updateExecutionState(run.id, { + waitingFor: context.waitingFor ?? "automatic retry", + stalledReason: `${context.messagePrefix}: ${errorMessage}`, + }); + updateRun(run.id, { + status: "retry_wait", + retryCount, + retryAfter, + lastError: errorMessage, + }); + appendEvent(run.id, context.eventType, "retry_wait", `${context.messagePrefix}: ${errorMessage}`, { + retryCount, + retryAfter, + delaySec, + }); + await syncWorkflowWorkpad({ + runId: run.id, + workflow, + note: `${context.messagePrefix}: ${errorMessage}. Retrying automatically.`, + waitingFor: context.waitingFor ?? "automatic retry", + }); + }; + const ensureExecutionLane = async ( run: LinearWorkflowRun, workflow: LinearWorkflowDefinition, + target: LinearWorkflowDefinition["target"], issue: NormalizedLinearIssue ): Promise => { - if (workflow.target.type === "mission" || workflow.target.type === "review_gate") { + if (target.type === "mission" || target.type === "review_gate") { return null; } if (run.executionLaneId) return run.executionLaneId; + if (target.laneSelection === "operator_prompt") { + return null; + } const preferredPrimary = await primaryLane(); - if (workflow.target.laneSelection !== "fresh_issue_lane") { + if (target.laneSelection !== "fresh_issue_lane") { return preferredPrimary.id; } const lane = await args.laneService.create({ - name: (workflow.target.freshLaneName?.trim() || `${issue.identifier} ${issue.title}`).slice(0, 72), + name: (target.freshLaneName?.trim() || `${issue.identifier} ${issue.title}`).slice(0, 72), description: `Linear workflow ${workflow.name} for ${issue.identifier}`, parentLaneId: preferredPrimary.id, }); @@ -705,6 +1005,7 @@ export function createLinearDispatcherService(args: { const ensureLinkedPr = async ( run: LinearWorkflowRun, workflow: LinearWorkflowDefinition, + target: LinearWorkflowDefinition["target"], laneIdOverride?: string | null ): Promise => { if (run.linkedPrId) { @@ -728,7 +1029,7 @@ export function createLinearDispatcherService(args: { return existing.id; } - if (!workflow.target.prStrategy) { + if (!target.prStrategy || target.prStrategy.kind === "manual") { return null; } @@ -785,8 +1086,22 @@ export function createLinearDispatcherService(args: { workflow: LinearWorkflowDefinition, step: LinearWorkflowStep, ): Promise => { - const targetStatus = resolveWorkflowTargetStatus(workflow.target.type, step.targetStatus); - if (workflow.target.type === "mission") { + const target = getActiveTarget(run, workflow); + const targetStatus = resolveWorkflowTargetStatus(target.type, step.targetStatus); + + // Check for a manual completion signal stored in the step payload by resolveRunAction. + const manualStepRow = getStepRows(run.id).find((r) => r.workflow_step_id === step.id); + if (manualStepRow) { + const manualPayload = safeJsonParse | null>(manualStepRow.payload_json, null); + if ( + manualPayload?.completionSource === "manual" + && manualPayload.stageId === buildTargetStageId(run, workflow, step) + ) { + return { state: "completed", payload: { ...manualPayload, targetStatus } }; + } + } + + if (target.type === "mission") { if (!run.linkedMissionId) { return { state: "failed", payload: { targetStatus, reason: "missing_mission_link" } }; } @@ -818,7 +1133,7 @@ export function createLinearDispatcherService(args: { }; } - if (workflow.target.type === "employee_session") { + if (target.type === "employee_session") { const launchContext = getLaunchContext(run.id); const identityKey = typeof launchContext.sessionIdentityKey === "string" ? launchContext.sessionIdentityKey.trim() : ""; const sessions = await args.agentChatService.listSessions(); @@ -875,7 +1190,7 @@ export function createLinearDispatcherService(args: { }; } - if (workflow.target.type === "worker_run" || workflow.target.type === "pr_resolution") { + if (target.type === "worker_run" || target.type === "pr_resolution") { if (!run.linkedWorkerRunId) { return { state: "failed", payload: { targetStatus, reason: "missing_worker_run_link" } }; } @@ -940,12 +1255,26 @@ export function createLinearDispatcherService(args: { workflow: LinearWorkflowDefinition, issue: NormalizedLinearIssue ): Promise & Record> => { - const worker = resolveWorker(workflow); - const employeeTarget = resolveEmployeeTarget(policy, workflow, issue); + const target = getActiveTarget(run, workflow); + const stageIndex = getActiveTargetStageIndex(run, workflow); + const totalStages = getTargetStages(workflow.target).length; + const override = run.executionContext?.employeeOverride ?? null; + const overrideSource = override ? "operator" : null; + const worker = resolveWorker(policy, target, override); + const employeeTarget = resolveEmployeeTarget(policy, target, issue, override); + + updateExecutionState(run.id, { + activeTargetType: target.type, + activeStageIndex: stageIndex, + totalStages, + downstreamPending: stageIndex < totalStages - 1, + employeeOverride: override, + overrideSource, + }); - if (workflow.target.type === "mission") { + if (target.type === "mission") { const rendered = args.templateService.renderTemplate({ - templateId: workflow.target.missionTemplate ?? "default", + templateId: target.missionTemplate ?? "default", issue, route: { workflowId: workflow.id, @@ -958,43 +1287,89 @@ export function createLinearDispatcherService(args: { prompt: rendered.prompt, priority: issue.priorityLabel === "urgent" ? "urgent" : issue.priorityLabel === "high" ? "high" : issue.priorityLabel === "low" ? "low" : "normal", autostart: false, - launchMode: workflow.target.runMode === "manual" ? "manual" : "autopilot", + launchMode: target.runMode === "manual" ? "manual" : "autopilot", employeeAgentId: worker?.id, - ...(workflow.target.prStrategy ? { executionPolicy: { prStrategy: workflow.target.prStrategy } } : {}), - ...(workflow.target.phaseProfile ? { phaseProfileId: workflow.target.phaseProfile } : {}), + ...(target.prStrategy ? { executionPolicy: { prStrategy: target.prStrategy } } : {}), + ...(target.phaseProfile ? { phaseProfileId: target.phaseProfile } : {}), }); await args.aiOrchestratorService.startMissionRun({ missionId: mission.id, - runMode: workflow.target.runMode === "manual" ? "manual" : "autopilot", + runMode: target.runMode === "manual" ? "manual" : "autopilot", metadata: { source: "linear_workflow", linearIssueId: issue.id, workflowId: workflow.id, }, }); + updateRun(run.id, { + linkedMissionId: mission.id, + status: "waiting_for_target", + }); + updateExecutionState(run.id, { + waitingFor: "delegated work", + stalledReason: null, + workerId: worker?.id ?? null, + workerSlug: worker?.slug ?? null, + sessionLabel: null, + }); const missionPatch: Partial & Record = { linkedMissionId: mission.id, status: "waiting_for_target", workerId: worker?.id ?? null, workerSlug: worker?.slug ?? null, + sessionLabel: null, + activeTargetType: target.type, }; return missionPatch; } - if (workflow.target.type === "employee_session") { + if (target.type === "employee_session") { if (!employeeTarget || !employeeTarget.identityKey) { appendEvent(run.id, "run.awaiting_delegation", "waiting", `No employee could be resolved for workflow '${workflow.name}'. Queued for manual delegation.`); emitRunEvent(run, "delegated", `Awaiting manual delegation for ${run.identifier}.`); + updateExecutionState(run.id, { + waitingFor: "manual delegation", + stalledReason: "No employee could be resolved for this workflow.", + employeeOverride: override, + overrideSource, + workerId: null, + workerSlug: null, + sessionLabel: employeeTarget?.label ?? null, + }); return { status: "awaiting_delegation" as LinearWorkflowRunStatus, + workerId: null, + workerSlug: null, + sessionLabel: employeeTarget?.label ?? null, + activeTargetType: target.type, + }; + } + const laneId = await ensureExecutionLane(run, workflow, target, issue); + if (!laneId) { + appendEvent(run.id, "run.awaiting_lane_choice", "waiting", `Workflow '${workflow.name}' is paused until an operator chooses an execution lane.`, { + laneSelection: target.laneSelection ?? "primary", + }); + updateExecutionState(run.id, { + waitingFor: "operator lane choice", + stalledReason: "Workflow contract requires an operator to choose the execution lane.", + employeeOverride: override, + overrideSource, + workerId: employeeTarget.worker?.id ?? null, + workerSlug: employeeTarget.worker?.slug ?? null, + sessionLabel: employeeTarget.label, + }); + return { + status: "awaiting_lane_choice" as LinearWorkflowRunStatus, + workerId: employeeTarget.worker?.id ?? null, + workerSlug: employeeTarget.worker?.slug ?? null, + sessionLabel: employeeTarget.label, + activeTargetType: target.type, }; } - const laneId = await ensureExecutionLane(run, workflow, issue); - if (!laneId) throw new Error(`Workflow '${workflow.name}' could not resolve a lane for employee session launch.`); const session = await args.agentChatService.ensureIdentitySession({ identityKey: employeeTarget.identityKey, laneId, - reuseExisting: workflow.target.sessionReuse !== "fresh_session" && workflow.target.laneSelection !== "fresh_issue_lane", + reuseExisting: target.sessionReuse !== "fresh_session" && target.laneSelection !== "fresh_issue_lane", }); if (employeeTarget.worker) { const taskKey = args.workerTaskSessionService.deriveTaskKey({ @@ -1030,9 +1405,23 @@ export function createLinearDispatcherService(args: { }, }); } + updateRun(run.id, { + executionLaneId: laneId, + linkedSessionId: session.id, + status: "waiting_for_target", + }); await args.agentChatService.sendMessage({ sessionId: session.id, - text: buildDelegationPrompt(run, workflow, issue), + text: buildDelegationPrompt(run, workflow, target, issue), + }); + updateExecutionState(run.id, { + waitingFor: "explicit completion", + stalledReason: null, + employeeOverride: override, + overrideSource, + workerId: employeeTarget.worker?.id ?? null, + workerSlug: employeeTarget.worker?.slug ?? null, + sessionLabel: employeeTarget.label, }); const sessionPatch: Partial & Record = { executionLaneId: laneId, @@ -1043,9 +1432,10 @@ export function createLinearDispatcherService(args: { sessionIdentityKey: employeeTarget.identityKey, sessionLabel: employeeTarget.label, laneId, + activeTargetType: target.type, }; - if (workflow.target.prStrategy && workflow.target.prTiming === "after_start") { - const linkedPrId = await ensureLinkedPr({ ...run, ...sessionPatch }, workflow, laneId); + if (target.prStrategy && target.prTiming === "after_start") { + const linkedPrId = await ensureLinkedPr({ ...run, ...sessionPatch }, workflow, target, laneId); if (linkedPrId) { sessionPatch.linkedPrId = linkedPrId; } @@ -1053,10 +1443,30 @@ export function createLinearDispatcherService(args: { return sessionPatch; } - if (workflow.target.type === "worker_run" || workflow.target.type === "pr_resolution") { + if (target.type === "worker_run" || target.type === "pr_resolution") { if (!worker) throw new Error(`Workflow '${workflow.name}' could not resolve a worker.`); - const laneId = await ensureExecutionLane(run, workflow, issue); - if (!laneId) throw new Error(`Workflow '${workflow.name}' could not resolve a lane for worker launch.`); + const laneId = await ensureExecutionLane(run, workflow, target, issue); + if (!laneId) { + appendEvent(run.id, "run.awaiting_lane_choice", "waiting", `Workflow '${workflow.name}' is paused until an operator chooses an execution lane.`, { + laneSelection: target.laneSelection ?? "primary", + }); + updateExecutionState(run.id, { + waitingFor: "operator lane choice", + stalledReason: "Workflow contract requires an operator to choose the execution lane.", + employeeOverride: override, + overrideSource, + workerId: worker.id, + workerSlug: worker.slug, + sessionLabel: null, + }); + return { + status: "awaiting_lane_choice" as LinearWorkflowRunStatus, + workerId: worker.id, + workerSlug: worker.slug, + sessionLabel: null, + activeTargetType: target.type, + }; + } const taskKey = args.workerTaskSessionService.deriveTaskKey({ agentId: worker.id, laneId, @@ -1093,26 +1503,42 @@ export function createLinearDispatcherService(args: { reason: "assignment", taskKey, issueKey: issue.identifier, - prompt: buildDelegationPrompt(run, workflow, issue), + prompt: buildDelegationPrompt(run, workflow, target, issue), context: { source: "linear_workflow", issueId: issue.id, workflowId: workflow.id, - prStrategy: workflow.target.prStrategy ?? null, + prStrategy: target.prStrategy ?? null, laneId, runId: run.id, }, }); + updateRun(run.id, { + executionLaneId: laneId, + linkedWorkerRunId: wake.runId, + status: target.type === "pr_resolution" ? "waiting_for_pr" : "waiting_for_target", + }); + updateExecutionState(run.id, { + waitingFor: target.type === "pr_resolution" ? "pull request progress" : "delegated work", + stalledReason: null, + employeeOverride: override, + overrideSource, + workerId: worker.id, + workerSlug: worker.slug, + sessionLabel: null, + }); const workerPatch: Partial & Record = { executionLaneId: laneId, linkedWorkerRunId: wake.runId, - status: workflow.target.type === "pr_resolution" ? "waiting_for_pr" : "waiting_for_target", + status: target.type === "pr_resolution" ? "waiting_for_pr" : "waiting_for_target", workerId: worker.id, workerSlug: worker.slug, + sessionLabel: null, laneId, + activeTargetType: target.type, }; - if (workflow.target.prStrategy && workflow.target.prTiming === "after_start") { - const linkedPrId = await ensureLinkedPr({ ...run, ...workerPatch }, workflow, laneId); + if (target.prStrategy && target.prTiming === "after_start") { + const linkedPrId = await ensureLinkedPr({ ...run, ...workerPatch }, workflow, target, laneId); if (linkedPrId) { workerPatch.linkedPrId = linkedPrId; } @@ -1120,9 +1546,16 @@ export function createLinearDispatcherService(args: { return workerPatch; } + updateExecutionState(run.id, { + waitingFor: "supervisor review", + stalledReason: null, + employeeOverride: override, + overrideSource, + }); return { reviewState: "pending", status: "awaiting_human_review", + activeTargetType: target.type, }; }; @@ -1220,6 +1653,48 @@ export function createLinearDispatcherService(args: { return fresh; }; + const preserveDurableLaunchAfterError = ( + runId: string, + workflow: LinearWorkflowDefinition, + policy: LinearWorkflowConfig, + error: unknown, + ): LinearWorkflowRun | null => { + const currentRow = getRunRow(runId); + if (!currentRow) return null; + const currentRun = toRun(currentRow); + if (!hasDurableLaunchMarker(currentRun)) { + return null; + } + + const currentStep = currentRun.currentStepId + ? workflow.steps.find((entry) => entry.id === currentRun.currentStepId) ?? null + : workflow.steps[currentRun.currentStepIndex] ?? null; + const currentStepRow = currentStep + ? getStepRows(runId).find((entry) => entry.workflow_step_id === currentStep.id) ?? null + : null; + + if (currentStep?.type === "launch_target" && currentStepRow && currentStepRow.status !== "completed") { + updateStep(currentStepRow.id, { + status: "completed", + completedAt: nowIso(), + payload: buildLaunchPayloadFromRun(currentRun, workflow, policy), + }); + updateRun(runId, { + currentStepIndex: currentRun.currentStepIndex + 1, + currentStepId: workflow.steps[currentRun.currentStepIndex + 1]?.id ?? null, + }); + } + + appendEvent( + runId, + "run.launch_preserved_after_error", + "warning", + `Preserved launched target after error: ${error instanceof Error ? error.message : String(error)}`, + buildLaunchPayloadFromRun(toRun(getRunRow(runId)!), workflow, policy), + ); + return toRun(getRunRow(runId)!); + }; + const advanceRun = async (runId: string, policy: LinearWorkflowConfig): Promise => { const row = getRunRow(runId); if (!row) return null; @@ -1235,34 +1710,36 @@ export function createLinearDispatcherService(args: { return toRun(getRunRow(run.id)!); } - const refreshedIssue = await refreshIssueSnapshot(run.id, run.issueId); - if (refreshedIssue && (refreshedIssue.stateType === "completed" || refreshedIssue.stateType === "canceled")) { - const reason = refreshedIssue.stateType === "completed" ? "completed" : "cancelled"; - appendEvent(run.id, "run.cancelled", "cancelled", `Issue ${run.identifier} is no longer open (state: ${refreshedIssue.stateName}).`); - await finalizeRun(run, workflow, "cancelled", `Issue ${run.identifier} was ${reason} externally.`); - return toRun(getRunRow(run.id)!); - } + try { + const refreshedIssue = await refreshIssueSnapshot(run.id, run.issueId); + if (refreshedIssue && (refreshedIssue.stateType === "completed" || refreshedIssue.stateType === "canceled")) { + const reason = refreshedIssue.stateType === "completed" ? "completed" : "cancelled"; + appendEvent(run.id, "run.cancelled", "cancelled", `Issue ${run.identifier} is no longer open (state: ${refreshedIssue.stateName}).`); + await finalizeRun(run, workflow, "cancelled", `Issue ${run.identifier} was ${reason} externally.`); + return toRun(getRunRow(run.id)!); + } - const steps = getStepRows(run.id); - for (let index = run.currentStepIndex; index < steps.length; index += 1) { - const stepRow = steps[index]!; - const step = workflow.steps.find((entry) => entry.id === stepRow.workflow_step_id); - if (!step) continue; - updateRun(run.id, { currentStepIndex: index, currentStepId: step.id, status: "in_progress" }); - if (stepRow.status === "completed") continue; - updateStep(stepRow.id, { status: "running", startedAt: stepRow.started_at ?? nowIso() }); + const steps = getStepRows(run.id); + for (let index = run.currentStepIndex; index < steps.length; index += 1) { + const stepRow = steps[index]!; + const step = workflow.steps.find((entry) => entry.id === stepRow.workflow_step_id); + if (!step) continue; + updateRun(run.id, { currentStepIndex: index, currentStepId: step.id, status: "in_progress" }); + if (stepRow.status === "completed") continue; + updateStep(stepRow.id, { status: "running", startedAt: stepRow.started_at ?? nowIso() }); if (step.type === "launch_target") { const liveRun = toRun(getRunRow(run.id)!); const patch = await executeTarget(liveRun, policy, workflow, liveRun.sourceIssueSnapshot as NormalizedLinearIssue); + const launchedTargetType = String((patch.activeTargetType as string | undefined) ?? liveRun.executionContext?.activeTargetType ?? liveRun.targetType); updateRun(run.id, { ...patch, currentStepIndex: index + 1, currentStepId: workflow.steps[index + 1]?.id ?? null }); updateStep(stepRow.id, { status: "completed", completedAt: nowIso(), payload: patch }); - appendEvent(run.id, "step.launch_target", "completed", `Launched ${workflow.target.type}.`, patch as Record); - emitRunEvent(toRun(getRunRow(run.id)!), "delegated", `Delegated to ${workflow.target.type}.`); + appendEvent(run.id, "step.launch_target", "completed", `Launched ${launchedTargetType}.`, patch as Record); + emitRunEvent(toRun(getRunRow(run.id)!), "delegated", `Delegated to ${launchedTargetType}.`); await syncWorkflowWorkpad({ runId: run.id, workflow, - note: `Delegated to ${workflow.target.type.replace(/_/g, " ")}.`, + note: `Delegated to ${launchedTargetType.replace(/_/g, " ")}.`, waitingFor: patch.status === "waiting_for_target" ? "delegated work" @@ -1272,12 +1749,15 @@ export function createLinearDispatcherService(args: { ? "supervisor review" : patch.status === "awaiting_delegation" ? "manual delegation" - : null, + : patch.status === "awaiting_lane_choice" + ? "operator lane choice" + : null, }); const nextStep = workflow.steps[index + 1] ?? null; const shouldPauseAfterLaunch = patch.status === "awaiting_human_review" || patch.status === "awaiting_delegation" + || patch.status === "awaiting_lane_choice" || (patch.status === "waiting_for_target" && nextStep?.type === "wait_for_target_status") || (patch.status === "waiting_for_pr" && nextStep?.type === "wait_for_pr"); if (shouldPauseAfterLaunch) { @@ -1288,12 +1768,24 @@ export function createLinearDispatcherService(args: { if (step.type === "wait_for_target_status") { const liveRun = toRun(getRunRow(run.id)!); + const activeTarget = getActiveTarget(liveRun, workflow); + const stages = getTargetStages(workflow.target); + const activeStageIndex = getActiveTargetStageIndex(liveRun, workflow); + const nextTarget = stages[activeStageIndex + 1] ?? null; const evaluation = await evaluateTargetCompletion(liveRun, workflow, step); if (evaluation.runPatch) { updateRun(run.id, evaluation.runPatch); } if (evaluation.state === "waiting") { - updateRun(run.id, { status: "waiting_for_target" }); + const waitFor = String((evaluation.payload as { waitingFor?: string }).waitingFor ?? "delegated work"); + updateExecutionState(run.id, { + waitingFor: waitFor, + stalledReason: + waitFor === "explicit_completion" + ? "Waiting for an explicit ADE completion signal." + : null, + }); + updateRun(run.id, { status: activeTarget.type === "pr_resolution" ? "waiting_for_pr" : "waiting_for_target" }); updateStep(stepRow.id, { status: "waiting", payload: evaluation.payload }); await syncWorkflowWorkpad({ runId: run.id, @@ -1302,11 +1794,15 @@ export function createLinearDispatcherService(args: { evaluation.payload && typeof evaluation.payload.sessionRelinked === "boolean" && evaluation.payload.sessionRelinked ? "Relinked the delegated session and resumed waiting." : "Delegated work is still in progress.", - waitingFor: String((evaluation.payload as { waitingFor?: string }).waitingFor ?? "delegated work"), + waitingFor: waitFor, }); return toRun(getRunRow(run.id)!); } if (evaluation.state === "failed" || evaluation.state === "cancelled") { + updateExecutionState(run.id, { + waitingFor: null, + stalledReason: `Target ${evaluation.state}.`, + }); updateStep(stepRow.id, { status: "failed", completedAt: nowIso(), payload: evaluation.payload }); await finalizeRun( toRun(getRunRow(run.id)!), @@ -1316,6 +1812,109 @@ export function createLinearDispatcherService(args: { ); return toRun(getRunRow(run.id)!); } + + if (nextTarget) { + const nextStageIndex = activeStageIndex + 1; + const currentOverride = liveRun.executionContext?.employeeOverride ?? null; + const shouldClearOverride = + Boolean(currentOverride) + && ( + nextTarget.type !== activeTarget.type + || !isEmployeeOverrideCompatibleWithTarget(policy, nextTarget.type, currentOverride) + ); + updateRun(run.id, { + linkedMissionId: null, + linkedSessionId: null, + linkedWorkerRunId: null, + }); + updateExecutionState(run.id, { + activeStageIndex: nextStageIndex, + activeTargetType: nextTarget.type, + downstreamPending: nextStageIndex < stages.length - 1, + waitingFor: null, + stalledReason: null, + ...(shouldClearOverride ? { employeeOverride: null, overrideSource: null } : {}), + }); + const stagedRun = toRun(getRunRow(run.id)!); + const plannedDownstreamTargetType = String(nextTarget.type); + const waitForPrIndex = + plannedDownstreamTargetType === "pr_resolution" + ? workflow.steps.findIndex((entry, stepIndex) => stepIndex > index && entry.type === "wait_for_pr") + : -1; + if (plannedDownstreamTargetType === "pr_resolution" && waitForPrIndex < 0) { + throw new Error(`Workflow '${workflow.name}' launched a PR-resolution stage without a later wait_for_pr step.`); + } + const downstreamPatch = await executeTarget(stagedRun, policy, workflow, stagedRun.sourceIssueSnapshot as NormalizedLinearIssue); + const downstreamTargetType = String((downstreamPatch.activeTargetType as string | undefined) ?? plannedDownstreamTargetType); + if ((downstreamPatch.status === "waiting_for_pr" || downstreamTargetType === "pr_resolution") && waitForPrIndex < 0) { + throw new Error(`Workflow '${workflow.name}' launched a PR-resolution stage without a later wait_for_pr step.`); + } + if (downstreamPatch.status === "waiting_for_pr" && waitForPrIndex >= 0) { + updateRun(run.id, { + ...downstreamPatch, + currentStepIndex: waitForPrIndex, + currentStepId: workflow.steps[waitForPrIndex]?.id ?? null, + }); + updateStep(stepRow.id, { + status: "completed", + completedAt: nowIso(), + payload: { + ...evaluation.payload, + downstreamStageIndex: nextStageIndex, + downstreamTargetType, + }, + }); + appendEvent(run.id, "run.downstream_target_started", "completed", `Started downstream stage ${downstreamTargetType}.`, { + downstreamStageIndex: nextStageIndex, + downstreamTargetType, + }); + await syncWorkflowWorkpad({ + runId: run.id, + workflow, + note: `Stage ${activeStageIndex + 1} finished; delegated to ${downstreamTargetType.replace(/_/g, " ")}.`, + waitingFor: "pull request", + }); + return toRun(getRunRow(run.id)!); + } + + const downstreamWaitingFor = + downstreamPatch.status === "awaiting_human_review" + ? "supervisor review" + : downstreamPatch.status === "awaiting_delegation" + ? "manual delegation" + : downstreamPatch.status === "awaiting_lane_choice" + ? "operator lane choice" + : downstreamPatch.status === "waiting_for_pr" + ? "pull request" + : "delegated work"; + updateRun(run.id, { + ...downstreamPatch, + currentStepIndex: index, + currentStepId: step.id, + }); + updateStep(stepRow.id, { + status: "waiting", + payload: { + ...stripManualCompletionState(evaluation.payload), + downstreamStageIndex: nextStageIndex, + downstreamTargetType, + waitingFor: downstreamWaitingFor, + }, + }); + appendEvent(run.id, "run.downstream_target_started", "completed", `Started downstream stage ${downstreamTargetType}.`, { + downstreamStageIndex: nextStageIndex, + downstreamTargetType, + }); + await syncWorkflowWorkpad({ + runId: run.id, + workflow, + note: `Stage ${activeStageIndex + 1} finished; delegated to ${downstreamTargetType.replace(/_/g, " ")}.`, + waitingFor: downstreamWaitingFor, + }); + return toRun(getRunRow(run.id)!); + } + + clearExecutionWaitState(run.id); if ((workflow.closeout?.reviewReadyWhen ?? "work_complete") === "work_complete") { updateRun(run.id, { reviewReadyReason: "work_complete" }); } @@ -1331,7 +1930,8 @@ export function createLinearDispatcherService(args: { if (step.type === "wait_for_pr") { const refreshed = toRun(getRunRow(run.id)!); - const linkedPrId = await ensureLinkedPr(refreshed, workflow); + const target = getActiveTarget(refreshed, workflow); + const linkedPrId = await ensureLinkedPr(refreshed, workflow, target); if (!linkedPrId) { updateRun(run.id, { status: "waiting_for_pr" }); updateStep(stepRow.id, { status: "waiting", payload: { waitingFor: "pr_link" } }); @@ -1445,6 +2045,13 @@ export function createLinearDispatcherService(args: { linkedSessionId: null, linkedWorkerRunId: null, }); + updateExecutionState(run.id, { + activeStageIndex: 0, + activeTargetType: null, + downstreamPending: false, + waitingFor: null, + stalledReason: null, + }); appendEvent(run.id, "run.review_changes_requested", "queued", liveRun.latestReviewNote ?? "Supervisor requested changes.", { reviewerIdentityKey: reviewContext.reviewerIdentityKey, loopToStepId: workflow.steps[loopIndex]?.id ?? null, @@ -1601,11 +2208,28 @@ export function createLinearDispatcherService(args: { updateStep(stepRow.id, { status: "completed", completedAt: nowIso() }); } - const finalRun = toRun(getRunRow(run.id)!); - if (finalRun.status !== "completed" && finalRun.status !== "failed" && finalRun.status !== "cancelled") { - await finalizeRun(finalRun, workflow, "completed", "Workflow completed successfully."); + const finalRun = toRun(getRunRow(run.id)!); + if (finalRun.status !== "completed" && finalRun.status !== "failed" && finalRun.status !== "cancelled") { + await finalizeRun(finalRun, workflow, "completed", "Workflow completed successfully."); + } + return toRun(getRunRow(run.id)!); + } catch (error) { + const preservedRun = preserveDurableLaunchAfterError(run.id, workflow, policy, error); + if (preservedRun) { + return preservedRun; + } + await scheduleRetry( + toRun(getRunRow(run.id) ?? row), + workflow, + error, + { + eventType: "run.retry_scheduled", + messagePrefix: "Workflow execution failed", + waitingFor: "automatic retry", + }, + ); + return toRun(getRunRow(run.id)!); } - return toRun(getRunRow(run.id)!); }; const createRun = (issue: NormalizedLinearIssue, match: LinearWorkflowMatchResult): LinearWorkflowRun => { @@ -1614,15 +2238,29 @@ export function createLinearDispatcherService(args: { } const now = nowIso(); const id = randomUUID(); + const routeContext: LinearWorkflowRouteContext = { + reason: match.reason, + matchedSignals: match.candidates.find((candidate) => candidate.workflowId === match.workflowId)?.matchedSignals ?? [], + routeTags: match.workflow.routing?.metadataTags ?? [], + watchOnly: match.workflow.routing?.watchOnly === true, + candidates: match.candidates, + }; + const executionContext: LinearWorkflowExecutionContext = { + activeTargetType: match.target.type, + activeStageIndex: 0, + totalStages: getTargetStages(match.target).length, + downstreamPending: getTargetStages(match.target).length > 1, + routeTags: match.workflow.routing?.metadataTags ?? [], + }; args.db.run( ` insert into linear_workflow_runs( id, project_id, issue_id, identifier, title, workflow_id, workflow_name, workflow_version, source, target_type, status, current_step_index, current_step_id, execution_lane_id, linked_mission_id, linked_session_id, linked_worker_run_id, linked_pr_id, review_state, supervisor_identity_key, review_ready_reason, pr_state, pr_checks_status, pr_review_status, latest_review_note, - retry_count, retry_after, closeout_state, terminal_outcome, source_issue_snapshot_json, last_error, created_at, updated_at + retry_count, retry_after, closeout_state, terminal_outcome, route_context_json, execution_context_json, source_issue_snapshot_json, last_error, created_at, updated_at ) - values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, null, null, null, null, null, null, null, null, null, null, null, null, 0, null, 'pending', null, ?, null, ?, ?) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, null, null, null, null, null, null, null, null, null, null, null, null, 0, null, 'pending', null, ?, ?, ?, null, ?, ?) `, [ id, @@ -1636,13 +2274,15 @@ export function createLinearDispatcherService(args: { match.workflow.source ?? "generated", match.target.type, match.workflow.steps[0]?.id ?? null, + JSON.stringify(routeContext), + JSON.stringify(executionContext), JSON.stringify(issue), now, now, ] ); - match.workflow.steps.forEach((step, index) => { + match.workflow.steps.forEach((step) => { args.db.run( ` insert into linear_workflow_run_steps( @@ -1654,9 +2294,11 @@ export function createLinearDispatcherService(args: { ); }); - appendEvent(id, "run.created", "queued", `Matched workflow '${match.workflow.name}'.`, { + appendEvent(id, "run.created", "queued", `Matched workflow '${match.workflow.name}'.`, { candidates: match.candidates, nextStepsPreview: match.nextStepsPreview, + routeTags: routeContext.routeTags, + matchedSignals: routeContext.matchedSignals, }); const created = toRun(getRunRow(id)!); emitRunEvent(created, "matched", `Matched workflow '${match.workflow.name}'.`); @@ -1670,7 +2312,7 @@ export function createLinearDispatcherService(args: { from linear_workflow_runs where project_id = ? and issue_id = ? - and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'retry_wait') + and status in ('queued', 'in_progress', 'waiting_for_target', 'waiting_for_pr', 'awaiting_human_review', 'awaiting_delegation', 'awaiting_lane_choice', 'retry_wait') order by datetime(created_at) desc limit 1 `, @@ -1701,7 +2343,8 @@ export function createLinearDispatcherService(args: { queueItemId: string, action: "approve" | "reject" | "retry" | "complete", note: string | undefined, - policy: LinearWorkflowConfig + policy: LinearWorkflowConfig, + employeeOverride?: string, ): Promise => { const row = getRunRow(queueItemId); const run = row ? toRun(row) : null; @@ -1709,26 +2352,46 @@ export function createLinearDispatcherService(args: { const workflow = policy.workflows.find((entry) => entry.id === run.workflowId); const currentStep = workflow?.steps.find((entry) => entry.id === run.currentStepId) ?? null; const currentStepRow = getStepRows(run.id).find((entry) => entry.workflow_step_id === run.currentStepId) ?? null; - const currentTargetStatus = workflow && currentStep?.type === "wait_for_target_status" - ? resolveWorkflowTargetStatus(workflow.target.type, currentStep.targetStatus) + const activeTarget = workflow ? getActiveTarget(run, workflow) : null; + const currentTargetStatus = workflow && currentStep?.type === "wait_for_target_status" && activeTarget + ? resolveWorkflowTargetStatus(activeTarget.type, currentStep.targetStatus) : null; const reviewContext = workflow && currentStep?.type === "request_human_review" ? buildReviewContext(workflow, currentStep) : null; + const trimmedOverride = employeeOverride?.trim(); + const resolvedOverride = trimmedOverride?.length ? resolveOverrideWorker(policy, trimmedOverride) : null; + if (trimmedOverride !== undefined) { + if ( + resolvedOverride === "cto" + && (activeTarget?.type === "worker_run" || activeTarget?.type === "pr_resolution") + ) { + throw new Error("Choose a worker override for worker-backed targets."); + } + updateExecutionState(run.id, { + employeeOverride: trimmedOverride && trimmedOverride.length ? trimmedOverride : null, + overrideSource: trimmedOverride && trimmedOverride.length ? "operator" : null, + }); + appendEvent(run.id, "run.override_updated", "queued", trimmedOverride?.length ? `Operator selected ${trimmedOverride}.` : "Operator cleared the employee override.", { + employeeOverride: trimmedOverride && trimmedOverride.length ? trimmedOverride : null, + }); + } if (action === "complete") { if (!workflow || !currentStep || !currentStepRow || currentStep.type !== "wait_for_target_status" || currentTargetStatus !== "explicit_completion") { throw new Error("This workflow run is not waiting on an explicit ADE completion signal."); } - const existingPayload = parsePayload>(currentStepRow.payload_json); + const existingPayload = safeJsonParse | null>(currentStepRow.payload_json, null); + // Store the manual-completion signal in the step payload but do NOT mark + // the step as "completed" — let advanceRun evaluate the target, handle + // downstream stage handoffs, and finalize the step through its normal path. updateStep(currentStepRow.id, { - status: "completed", - completedAt: nowIso(), payload: { ...(existingPayload ?? {}), targetState: "completed", targetStatus: currentTargetStatus, completionSource: "manual", + stageId: buildTargetStageId(run, workflow, currentStep), note: note ?? null, }, }); @@ -1740,15 +2403,6 @@ export function createLinearDispatcherService(args: { stepId: currentStep.id, targetStatus: currentTargetStatus, }); - if ((workflow.closeout?.reviewReadyWhen ?? "work_complete") === "work_complete") { - updateRun(run.id, { reviewReadyReason: "work_complete" }); - } - maybeEmitReviewReady(run.id, workflow, findStepIndex(workflow, currentStep.id), "work_complete", "Delegated work was marked complete in ADE."); - await syncWorkflowWorkpad({ - runId: run.id, - workflow, - note: note ?? "Delegated work was marked complete in ADE.", - }); } else if (action === "approve") { if (currentStepRow && currentStep?.type === "request_human_review") { updateStep(currentStepRow.id, { @@ -1782,7 +2436,21 @@ export function createLinearDispatcherService(args: { latestReviewNote: note ?? "Rejected by reviewer.", status: reviewContext?.rejectAction === "loop_back" ? "queued" : "in_progress", lastError: note ?? "Rejected by reviewer.", + ...(reviewContext?.rejectAction === "loop_back" ? { + linkedMissionId: null, + linkedSessionId: null, + linkedWorkerRunId: null, + } : {}), }); + if (reviewContext?.rejectAction === "loop_back") { + updateExecutionState(run.id, { + activeStageIndex: 0, + activeTargetType: null, + downstreamPending: false, + waitingFor: null, + stalledReason: null, + }); + } appendEvent(run.id, "run.rejected", "queued", note ?? "Rejected by reviewer.", { reviewerIdentityKey: reviewContext?.reviewerIdentityKey ?? null, rejectAction: reviewContext?.rejectAction ?? null, @@ -1796,11 +2464,52 @@ export function createLinearDispatcherService(args: { }); } } else { + const resetStep = (stepRow: StepRow | null) => { + if (!stepRow) return; + updateStep(stepRow.id, { + status: "pending", + startedAt: null, + completedAt: null, + payload: null, + }); + }; + const launchTargetStepRow = + currentStepRow?.type === "launch_target" + ? currentStepRow + : getStepRows(run.id).find((entry) => entry.type === "launch_target") ?? null; + const shouldResetLaunchTarget = + launchTargetStepRow?.status === "completed" + && ( + run.status === "awaiting_delegation" + || run.status === "awaiting_lane_choice" + || currentStep?.type === "wait_for_target_status" + || currentStep?.type === "wait_for_pr" + ); + if (currentStepRow?.status === "failed") { + resetStep(currentStepRow); + } + if (shouldResetLaunchTarget && launchTargetStepRow?.id !== currentStepRow?.id) { + resetStep(launchTargetStepRow); + } else if (shouldResetLaunchTarget) { + resetStep(launchTargetStepRow); + } updateRun(run.id, { status: "queued", retryAfter: null, retryCount: run.retryCount + 1, + currentStepIndex: 0, + currentStepId: null, latestReviewNote: note ?? run.latestReviewNote, + linkedMissionId: null, + linkedSessionId: null, + linkedWorkerRunId: null, + }); + updateExecutionState(run.id, { + activeStageIndex: 0, + activeTargetType: null, + downstreamPending: false, + waitingFor: null, + stalledReason: null, }); appendEvent(run.id, "run.retried", "queued", note ?? "Queued for retry.", null); } @@ -1820,13 +2529,15 @@ export function createLinearDispatcherService(args: { [args.projectId] ); return rows.map((row) => { + const queueRun = toRun(row); const steps = getStepRows(row.id); const currentStep = steps.find((entry) => entry.workflow_step_id === row.current_step_id) ?? null; - const launchContext = parsePayload>( - steps.find((entry) => entry.type === "launch_target")?.payload_json ?? null + const launchContext = safeJsonParse | null>( + steps.find((entry) => entry.type === "launch_target")?.payload_json ?? null, null ) ?? {}; + const delegatedContext = readDelegationContext(queueRun, launchContext); const status: LinearSyncQueueItem["status"] = - row.status === "queued" || row.status === "awaiting_delegation" + row.status === "queued" || row.status === "awaiting_delegation" || row.status === "awaiting_lane_choice" ? "queued" : row.status === "retry_wait" ? "retry_wait" @@ -1850,8 +2561,9 @@ export function createLinearDispatcherService(args: { workflowName: row.workflow_name, targetType: row.target_type, laneId: row.execution_lane_id ?? (typeof launchContext.laneId === "string" ? launchContext.laneId : null), - workerId: typeof launchContext.workerId === "string" ? launchContext.workerId : null, - workerSlug: typeof launchContext.workerSlug === "string" ? launchContext.workerSlug : null, + workerId: delegatedContext.workerId, + workerSlug: delegatedContext.workerSlug, + sessionLabel: delegatedContext.sessionLabel, missionId: row.linked_mission_id, sessionId: row.linked_session_id, workerRunId: row.linked_worker_run_id, @@ -1868,6 +2580,13 @@ export function createLinearDispatcherService(args: { attemptCount: row.retry_count, nextAttemptAt: row.retry_after, lastError: row.last_error, + routeReason: queueRun.routeContext?.reason ?? null, + matchedSignals: queueRun.routeContext?.matchedSignals ?? [], + routeTags: queueRun.routeContext?.routeTags ?? [], + stalledReason: queueRun.executionContext?.stalledReason ?? null, + waitingFor: queueRun.executionContext?.waitingFor ?? null, + employeeOverride: queueRun.executionContext?.employeeOverride ?? null, + activeTargetType: queueRun.executionContext?.activeTargetType ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -1900,6 +2619,35 @@ export function createLinearDispatcherService(args: { [args.projectId, refreshed.issueId] ) as LinearIngressEventRecord[], issue: refreshed.sourceIssueSnapshot as NormalizedLinearIssue, + syncEvents: args.db.all<{ + id: string; + issueId: string | null; + queueItemId: string | null; + eventType: string; + status: string | null; + message: string | null; + payload: string | null; + createdAt: string; + }>( + ` + select id, issue_id as issueId, queue_item_id as queueItemId, event_type as eventType, status, message, payload_json as payload, created_at as createdAt + from linear_sync_events + where project_id = ? + and (issue_id = ? or queue_item_id = ?) + order by datetime(created_at) desc + limit 12 + `, + [args.projectId, refreshed.issueId, refreshed.id] + ).map((entry) => ({ + id: entry.id, + issueId: entry.issueId, + queueItemId: entry.queueItemId, + eventType: entry.eventType, + status: entry.status, + message: entry.message, + payload: safeJsonParse | null>(entry.payload, null), + createdAt: entry.createdAt, + })), reviewContext: currentStep?.type === "request_human_review" ? buildReviewContext(workflow!, currentStep) : null, diff --git a/apps/desktop/src/main/services/cto/linearIntakeService.test.ts b/apps/desktop/src/main/services/cto/linearIntakeService.test.ts new file mode 100644 index 000000000..d8da46d03 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearIntakeService.test.ts @@ -0,0 +1,283 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; +import { openKvDb } from "../state/kvDb"; +import { createLinearIntakeService } from "./linearIntakeService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug"], + assigneeId: null, + assigneeName: "CTO", + ownerId: "owner-1", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, +}; + +const secondIssue: NormalizedLinearIssue = { + ...issueFixture, + id: "issue-2", + identifier: "ABC-43", + title: "Add rate limiter", + priority: 1, + createdAt: "2026-03-04T00:00:00.000Z", + updatedAt: "2026-03-04T00:00:00.000Z", +}; + +const policy: LinearWorkflowConfig = { + version: 1, + source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "flow-1", + name: "Flow 1", + enabled: true, + priority: 100, + triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, + target: { type: "mission" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, +}; + +async function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-intake-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); + return { root, adeDir, db }; +} + +describe("linearIntakeService", () => { + it("fetches candidates, filters out blockers, and sorts by priority then createdAt", async () => { + const fixture = await createFixture(); + const blockedIssue: NormalizedLinearIssue = { + ...issueFixture, + id: "issue-blocked", + identifier: "ABC-99", + hasOpenBlockers: true, + priority: 0, + }; + const fetchCandidateIssues = vi.fn(async () => [blockedIssue, secondIssue, issueFixture]); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-intake-test", + issueTracker: { + fetchCandidateIssues, + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + + expect(fetchCandidateIssues).toHaveBeenCalledWith({ + projectSlugs: ["acme-platform"], + stateTypes: ["backlog", "unstarted", "started"], + }); + + // Blocked issue should be filtered out + expect(candidates.find((issue) => issue.id === "issue-blocked")).toBeUndefined(); + // Remaining should be sorted by priority (ascending), then createdAt + expect(candidates).toHaveLength(2); + expect(candidates[0]!.id).toBe("issue-2"); // priority 1 < priority 2 + expect(candidates[1]!.id).toBe("issue-1"); + + fixture.db.close(); + }); + + it("merges project slugs from intake, workflows, and legacy config", async () => { + const fixture = await createFixture(); + const fetchCandidateIssues = vi.fn(async () => []); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-slug-merge", + issueTracker: { fetchCandidateIssues } as any, + }); + + const policyWithLegacy: LinearWorkflowConfig = { + ...policy, + intake: { + ...policy.intake, + projectSlugs: ["primary-project"], + }, + workflows: [ + { + id: "flow-extra", + name: "Extra flow", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["extra-project"] }, + target: { type: "mission" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + ], + legacyConfig: { + enabled: true, + projects: [{ slug: "legacy-project" }], + }, + }; + + await service.fetchCandidates(policyWithLegacy); + + const calledWith = (fetchCandidateIssues.mock.calls as any)[0][0] as { projectSlugs: string[] }; + expect(calledWith.projectSlugs).toContain("primary-project"); + expect(calledWith.projectSlugs).toContain("extra-project"); + expect(calledWith.projectSlugs).toContain("legacy-project"); + + fixture.db.close(); + }); + + it("attaches previous state info from persisted snapshots", async () => { + const fixture = await createFixture(); + const projectId = "project-previous-state"; + + // Pre-persist a snapshot so the service finds previous state + const now = new Date().toISOString(); + fixture.db.run( + ` + insert into linear_issue_snapshots( + id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + `${projectId}:${issueFixture.id}`, + projectId, + issueFixture.id, + issueFixture.identifier, + "backlog", + null, + issueFixture.updatedAt, + JSON.stringify({ ...issueFixture, stateId: "state-backlog", stateName: "Backlog", stateType: "backlog" }), + "old-hash", + now, + now, + ] + ); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId, + issueTracker: { + fetchCandidateIssues: vi.fn(async () => [issueFixture]), + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.previousStateType).toBe("backlog"); + expect(candidates[0]!.previousStateName).toBe("Backlog"); + + fixture.db.close(); + }); + + it("persistSnapshot inserts a new row and updates an existing one", async () => { + const fixture = await createFixture(); + const projectId = "project-persist-test"; + + const service = createLinearIntakeService({ + db: fixture.db, + projectId, + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + // First persist: insert + service.persistSnapshot(issueFixture); + const row1 = fixture.db.get<{ issue_id: string; state_type: string }>( + `select issue_id, state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, + [projectId, issueFixture.id] + ); + expect(row1, "First persist should create a row").toBeTruthy(); + expect(row1!.issue_id).toBe(issueFixture.id); + expect(row1!.state_type).toBe("unstarted"); + + // Second persist: update + const updatedIssue = { ...issueFixture, stateType: "started" as const, stateName: "In Progress" }; + service.persistSnapshot(updatedIssue); + const row2 = fixture.db.get<{ state_type: string }>( + `select state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, + [projectId, issueFixture.id] + ); + expect(row2!.state_type).toBe("started"); + + fixture.db.close(); + }); + + it("issueHash produces consistent deterministic output", async () => { + const fixture = await createFixture(); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-hash-test", + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + const hash1 = service.issueHash(issueFixture); + const hash2 = service.issueHash(issueFixture); + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // sha256 hex + + const hash3 = service.issueHash({ ...issueFixture, title: "Different title" }); + expect(hash3).not.toBe(hash1); + + fixture.db.close(); + }); + + it("returns empty array when no issues match the query", async () => { + const fixture = await createFixture(); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-empty", + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + expect(candidates).toEqual([]); + + fixture.db.close(); + }); +}); diff --git a/apps/desktop/src/main/services/cto/linearIntakeService.ts b/apps/desktop/src/main/services/cto/linearIntakeService.ts index ff66a47ea..894cdc697 100644 --- a/apps/desktop/src/main/services/cto/linearIntakeService.ts +++ b/apps/desktop/src/main/services/cto/linearIntakeService.ts @@ -30,16 +30,20 @@ export function createLinearIntakeService(args: { projectId: string; issueTracker: IssueTracker; }) { + const uniqueStrings = (values: Array): string[] => + Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean))); + const fetchCandidates = async (policy: LinearWorkflowConfig): Promise => { - const projectSlugs = Array.from( - new Set( - policy.workflows.flatMap((workflow) => workflow.triggers.projectSlugs ?? []).filter(Boolean) - ) - ); - const querySlugs = projectSlugs.length ? projectSlugs : (policy.legacyConfig?.projects ?? []).map((entry) => entry.slug); + const workflowProjectSlugs = policy.workflows.flatMap((workflow) => workflow.triggers.projectSlugs ?? []).filter(Boolean); + const querySlugs = uniqueStrings([ + ...(policy.intake.projectSlugs ?? []), + ...workflowProjectSlugs, + ...((policy.legacyConfig?.projects ?? []).map((entry) => entry.slug)), + ]); + const activeStateTypes = uniqueStrings(policy.intake.activeStateTypes ?? ["backlog", "unstarted", "started"]); const issues = await args.issueTracker.fetchCandidateIssues({ projectSlugs: querySlugs, - stateTypes: ["backlog", "unstarted", "started"], + stateTypes: activeStateTypes, }); const eligible = issues.filter((issue) => !issue.hasOpenBlockers); diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.test.ts b/apps/desktop/src/main/services/cto/linearOAuthService.test.ts new file mode 100644 index 000000000..367199245 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearOAuthService.test.ts @@ -0,0 +1,363 @@ +import http from "node:http"; +import { describe, expect, it, vi, afterEach } from "vitest"; +import { createLinearOAuthService } from "./linearOAuthService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: vi.fn(), + error: () => {}, + } as any; +} + +function createCredentialsMock(overrides?: { + clientSecret?: string | null; +}) { + return { + getOAuthClientCredentials: vi.fn(() => ({ + clientId: "test-client-id", + clientSecret: overrides?.clientSecret ?? "test-client-secret", + })), + setOAuthToken: vi.fn(), + }; +} + +/** + * HTTP GET that tolerates early server close. + * + * The OAuth service calls `server.close()` immediately after writing + * its response in error paths. Node's http client may see a socket + * hang-up before the response is fully consumed. We capture whatever + * status code was received; if none, resolve with statusCode 0 so + * tests can still assert on session state via `getSession`. + */ +function httpGet(url: string): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve) => { + const parsed = new URL(url); + let resolved = false; + let statusCode = 0; + let body = ""; + + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: "GET", + }, + (res) => { + statusCode = res.statusCode ?? 0; + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + res.on("error", () => { + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + } + ); + req.on("error", () => { + // Server closed before we could read the full response. + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + req.setTimeout(5000, () => { + req.destroy(); + if (!resolved) { resolved = true; resolve({ statusCode: 0, body: "" }); } + }); + req.end(); + }); +} + +const activeServices: Array> = []; + +function waitMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForSessionStatus( + service: ReturnType, + sessionId: string, + expectedStatus: string, + timeoutMs = 3000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const session = service.getSession(sessionId); + if (session.status === expectedStatus) return; + await waitMs(10); + } + // Final check that will throw a clear assertion error + const session = service.getSession(sessionId); + expect(session.status, `Timed out waiting for session ${sessionId} to reach status '${expectedStatus}'`).toBe(expectedStatus); +} + +afterEach(async () => { + for (const svc of activeServices) { + svc.dispose(); + } + activeServices.length = 0; + // Allow port to fully release between tests + await waitMs(50); +}); + +describe("linearOAuthService", () => { + it("throws when OAuth client credentials are not configured", async () => { + const service = createLinearOAuthService({ + credentials: { + getOAuthClientCredentials: vi.fn(() => null), + setOAuthToken: vi.fn(), + } as any, + logger: createLogger(), + }); + activeServices.push(service); + + await expect(service.startSession()).rejects.toThrow("not configured"); + }); + + it("starts a session and returns a valid authUrl with required OAuth params", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + + expect(result.sessionId).toBeTruthy(); + expect(result.sessionId.startsWith("linear-oauth-")).toBe(true); + expect(result.authUrl).toContain("linear.app/oauth/authorize"); + expect(result.authUrl).toContain("client_id=test-client-id"); + expect(result.authUrl).toContain("response_type=code"); + expect(result.authUrl).toContain("scope=read"); + expect(result.authUrl).toContain("prompt=consent"); + expect(result.redirectUri).toContain("/oauth/callback"); + + const session = service.getSession(result.sessionId); + expect(session.status).toBe("pending"); + expect(session.error).toBeNull(); + }); + + it("getSession returns expired for unknown session id", () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const session = service.getSession("nonexistent-session"); + expect(session.status).toBe("expired"); + expect(session.error).toContain("not found"); + }); + + it("exchanges authorization code for access token via the callback", async () => { + const credentials = createCredentialsMock(); + const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "linear-access-token-123", + refresh_token: "linear-refresh-token-456", + expires_in: 3600, + }), + })) as any; + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + + // Extract the state parameter from the authUrl + const stateParam = new URL(authUrl).searchParams.get("state")!; + expect(stateParam).toBeTruthy(); + + // Simulate the OAuth callback + const callbackUrl = `${redirectUri}?code=test-code-123&state=${stateParam}`; + const response = await httpGet(callbackUrl); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Linear connected"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]![1] as { body: string }; + expect(fetchCall.body).toContain("code=test-code-123"); + expect(fetchCall.body).toContain("client_id=test-client-id"); + expect(fetchCall.body).toContain("client_secret=test-client-secret"); + + expect(credentials.setOAuthToken).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "linear-access-token-123", + refreshToken: "linear-refresh-token-456", + }) + ); + + const session = service.getSession(sessionId); + expect(session.status).toBe("completed"); + expect(session.error).toBeNull(); + }); + + it("handles OAuth callback with error parameter from Linear", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?error=access_denied&error_description=User+declined&state=${stateParam}`; + await httpGet(callbackUrl); + + // The server may close before the HTTP response is fully consumed, + // so we wait for the session state to transition. + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("User declined"); + }); + + it("handles OAuth callback with state mismatch", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, redirectUri } = await service.startSession(); + + const callbackUrl = `${redirectUri}?code=test-code&state=wrong-state`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("state did not match"); + }); + + it("handles OAuth callback without authorization code", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?state=${stateParam}`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("did not include an authorization code"); + }); + + it("handles token exchange failure gracefully", async () => { + const credentials = createCredentialsMock(); + const mockFetch = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "The authorization code has expired.", + }), + })) as any; + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?code=expired-code&state=${stateParam}`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("expired"); + }); + + it("supersedes previous pending sessions when starting a new one", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const first = await service.startSession(); + expect(service.getSession(first.sessionId).status).toBe("pending"); + + const second = await service.startSession(); + expect(service.getSession(second.sessionId).status).toBe("pending"); + + // First session should be superseded + const firstStatus = service.getSession(first.sessionId); + expect(firstStatus.status).toBe("expired"); + expect(firstStatus.error).toContain("Superseded"); + }); + + it("dispose clears all sessions and closes servers", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + // Do NOT push to activeServices since we call dispose manually + const { sessionId } = await service.startSession(); + + service.dispose(); + + const session = service.getSession(sessionId); + expect(session.status).toBe("expired"); + }); + + it("uses PKCE flow when no client secret is provided", async () => { + const credentials = createCredentialsMock({ clientSecret: null }); + credentials.getOAuthClientCredentials.mockReturnValue({ + clientId: "public-client-id", + clientSecret: null as any, + }); + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + const authUrl = new URL(result.authUrl); + + expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authUrl.searchParams.get("code_challenge")).toBeTruthy(); + expect(authUrl.searchParams.get("client_id")).toBe("public-client-id"); + }); + + it("does not use PKCE when client secret is provided", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + const authUrl = new URL(result.authUrl); + + // PKCE params should not be present when client_secret is available + expect(authUrl.searchParams.get("code_challenge_method")).toBeNull(); + expect(authUrl.searchParams.get("code_challenge")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 93a198a7f..b20e3b297 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -182,7 +182,7 @@ export function createLinearOAuthService(args: { await exchangeCode(session, code); finalizeSession(session, { status: "completed" }); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - res.end("Linear connected. You can close this window and return to ADE."); + res.end("Linear connected. You can close this window and return to ADE."); } catch (error) { const message = error instanceof Error ? error.message : "OAuth callback failed."; finalizeSession(session, { status: "failed", error: message }); diff --git a/apps/desktop/src/main/services/cto/linearOutboundService.test.ts b/apps/desktop/src/main/services/cto/linearOutboundService.test.ts index 0874d5e21..e4d74b9be 100644 --- a/apps/desktop/src/main/services/cto/linearOutboundService.test.ts +++ b/apps/desktop/src/main/services/cto/linearOutboundService.test.ts @@ -309,4 +309,59 @@ describe("linearOutboundService", () => { expect(latest).not.toContain("- Target:"); db.close(); }); + + it("renders comment templates for workflow status and closeout bodies", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-comment-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const createBodies: string[] = []; + const bodies: string[] = []; + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment: vi.fn(async (_issueId: string, body: string) => { + createBodies.push(body); + return { commentId: "comment-1" }; + }), + updateComment: vi.fn(async (_commentId: string, body: string) => { + bodies.push(body); + }), + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishWorkflowStatus({ + issue: issueFixture, + workflowName: "Assigned worker run", + runId: "run-7", + targetType: "worker_run", + state: "waiting_for_target", + note: "Delegated the issue.", + waitingFor: "delegated work", + commentTemplate: [ + "Issue {{ issue.identifier }}", + "Workflow {{ workflow.name }}", + "Target {{ target.type }}", + "Note {{ note }}", + ].join("\n"), + }); + + await service.publishWorkflowCloseout({ + issue: issueFixture, + status: "completed", + summary: "Closed.", + targetLabel: "worker run", + targetId: "worker-22", + artifactMode: "links", + commentTemplate: "Closeout {{ issue.identifier }} {{ target.id }} {{ note }}", + }); + + expect(createBodies[0]).toContain("Issue ABC-12"); + expect(createBodies[0]).toContain("Workflow Assigned worker run"); + expect(createBodies[0]).toContain("Target worker_run"); + expect(bodies[0]).toContain("Closeout ABC-12 worker-22 Closed."); + db.close(); + }); }); diff --git a/apps/desktop/src/main/services/cto/linearOutboundService.ts b/apps/desktop/src/main/services/cto/linearOutboundService.ts index 402e8f1c3..9606c05a9 100644 --- a/apps/desktop/src/main/services/cto/linearOutboundService.ts +++ b/apps/desktop/src/main/services/cto/linearOutboundService.ts @@ -5,7 +5,7 @@ import type { LinearArtifactMode, NormalizedLinearIssue } from "../../../shared/ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { IssueTracker } from "./issueTracker"; -import { nowIso, uniqueStrings, getErrorMessage } from "../shared/utils"; +import { nowIso, uniqueStrings, getErrorMessage, renderTemplateString } from "../shared/utils"; function bodyHash(body: string): string { return createHash("sha256").update(body).digest("hex"); @@ -205,8 +205,10 @@ export function createLinearOutboundService(args: { reviewReadyReason?: string | null; waitingFor?: string | null; note?: string | null; + commentTemplate?: string | null; + templateValues?: Record; }): Promise => { - const body = [ + const defaultBody = [ buildHeader(params.issue), "### Workflow", `- Workflow: ${params.workflowName}`, @@ -227,6 +229,42 @@ export function createLinearOutboundService(args: { "### Latest update", params.note?.trim() || "Workflow run updated.", ].join("\n"); + const renderedTemplate = params.commentTemplate?.trim() + ? normalizeText( + renderTemplateString(params.commentTemplate, { + issue: params.issue, + workflow: { + name: params.workflowName, + }, + run: { + id: params.runId, + status: params.state, + currentStep: params.currentStep ?? null, + laneId: params.laneId ?? null, + missionId: params.missionId ?? null, + sessionId: params.sessionId ?? null, + workerRunId: params.workerRunId ?? null, + prId: params.prId ?? null, + }, + target: { + type: params.targetType, + owner: params.delegatedOwner ?? null, + id: params.sessionId ?? params.workerRunId ?? params.missionId ?? params.prId ?? params.laneId ?? null, + }, + pr: { + id: params.prId ?? null, + }, + review: { + state: params.reviewState ?? null, + readyReason: params.reviewReadyReason ?? null, + }, + note: params.note ?? null, + waitingFor: params.waitingFor ?? null, + ...(params.templateValues ?? {}), + }), + ) + : ""; + const body = renderedTemplate.length ? renderedTemplate : defaultBody; await updateWorkpad({ issueId: params.issue.id, body }); }; @@ -286,6 +324,8 @@ export function createLinearOutboundService(args: { prLinks?: string[]; artifactPaths?: string[]; artifactMode: LinearArtifactMode; + commentTemplate?: string | null; + templateValues?: Record; }): Promise => { await publishWorkflowCloseout({ issue: params.issue, @@ -296,6 +336,8 @@ export function createLinearOutboundService(args: { prLinks: params.prLinks, artifactPaths: params.artifactPaths, artifactMode: params.artifactMode, + commentTemplate: params.commentTemplate, + templateValues: params.templateValues, }); }; @@ -309,6 +351,8 @@ export function createLinearOutboundService(args: { prLinks?: string[]; artifactPaths?: string[]; artifactMode: LinearArtifactMode; + commentTemplate?: string | null; + templateValues?: Record; }): Promise => { const prLinks = uniqueStrings((params.prLinks ?? []).filter((entry) => entry.trim().length > 0)); const uploadedArtifacts = await uploadArtifacts({ @@ -317,7 +361,7 @@ export function createLinearOutboundService(args: { mode: params.artifactMode, }); - const body = [ + const defaultBody = [ buildHeader(params.issue), "### Status", `- Final state: ${params.status}`, @@ -335,6 +379,30 @@ export function createLinearOutboundService(args: { ? ["", "### Artifacts", ...uploadedArtifacts.map((link) => `- ${link}`)] : []), ].join("\n"); + const renderedTemplate = params.commentTemplate?.trim() + ? normalizeText( + renderTemplateString(params.commentTemplate, { + issue: params.issue, + workflow: {}, + run: { + status: params.status, + }, + target: { + label: params.targetLabel, + id: params.targetId ?? null, + }, + pr: { + links: prLinks, + }, + review: {}, + note: params.summary, + waitingFor: null, + artifacts: uploadedArtifacts, + ...(params.templateValues ?? {}), + }), + ) + : ""; + const body = renderedTemplate.length ? renderedTemplate : defaultBody; await updateWorkpad({ issueId: params.issue.id, body }); }; diff --git a/apps/desktop/src/main/services/cto/linearRoutingService.test.ts b/apps/desktop/src/main/services/cto/linearRoutingService.test.ts index 79a779c9a..594bda483 100644 --- a/apps/desktop/src/main/services/cto/linearRoutingService.test.ts +++ b/apps/desktop/src/main/services/cto/linearRoutingService.test.ts @@ -38,6 +38,11 @@ function buildPolicy(): LinearWorkflowConfig { return { version: 1, source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows: [ { diff --git a/apps/desktop/src/main/services/cto/linearSyncService.test.ts b/apps/desktop/src/main/services/cto/linearSyncService.test.ts index 68495efb1..bbd044b4a 100644 --- a/apps/desktop/src/main/services/cto/linearSyncService.test.ts +++ b/apps/desktop/src/main/services/cto/linearSyncService.test.ts @@ -37,6 +37,11 @@ const issueFixture: NormalizedLinearIssue = { const policy: LinearWorkflowConfig = { version: 1, source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows: [], files: [], @@ -70,7 +75,15 @@ describe("linearSyncService", () => { })), } as any, intakeService: { - fetchCandidates: vi.fn(async () => [issueFixture]), + fetchCandidates: vi.fn(async () => [ + { + ...issueFixture, + raw: { + _snapshotHash: "hash-1", + _previousSnapshotHash: "hash-0", + }, + }, + ]), persistSnapshot: vi.fn(() => {}), } as any, issueTracker: { @@ -199,4 +212,314 @@ describe("linearSyncService", () => { expect(advanceRun).toHaveBeenCalledWith("run-1", policy); db.close(); }); + + it("records watch-only matches in the dashboard without creating runs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-watch-only-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const createRun = vi.fn(); + + const watchOnlyIssue: NormalizedLinearIssue = { + ...issueFixture, + raw: { _snapshotHash: "hash-new", _previousSnapshotHash: "hash-old" }, + }; + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "watch-only", + name: "Watch only", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: { watchOnly: true }, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: "watch-only", + workflowName: "Watch only", + workflow: { + id: "watch-only", + name: "Watch only", + enabled: true, + priority: 100, + routing: { watchOnly: true }, + concurrency: {}, + }, + target: { type: "review_gate" }, + reason: "Matched watch-only workflow", + candidates: [{ workflowId: "watch-only", workflowName: "Watch only", priority: 100, matched: true, reasons: ["Project matched"], matchedSignals: ["Project matched"] }], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => [watchOnlyIssue]), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.runSyncNow(); + expect(createRun).not.toHaveBeenCalled(); + const dashboard = service.getDashboard(); + expect(dashboard.watchOnlyHits).toBe(1); + expect(dashboard.recentEvents[0]?.eventType).toBe("watch_only_match"); + db.close(); + }); + + it("hydrates webhook issue updates with snapshot hashes before routing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-webhook-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const createRun = vi.fn(() => ({ id: "run-1" })); + + db.run( + ` + insert into linear_issue_snapshots( + id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "project-1:issue-1", + "project-1", + "issue-1", + issueFixture.identifier, + "backlog", + null, + "2026-03-04T00:00:00.000Z", + JSON.stringify({ + ...issueFixture, + stateId: "state-backlog", + stateName: "Backlog", + stateType: "backlog", + }), + "hash-previous", + "2026-03-04T00:00:00.000Z", + "2026-03-04T00:00:00.000Z", + ], + ); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: {}, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: "workflow-1", + workflowName: "Dispatch issue", + workflow: { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + routing: {}, + concurrency: {}, + }, + target: { type: "review_gate" }, + reason: "Matched project", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.processIssueUpdate("issue-1"); + + expect(createRun).toHaveBeenCalledTimes(1); + expect(createRun).toHaveBeenCalledWith( + expect.objectContaining({ + raw: expect.objectContaining({ + _snapshotHash: "hash-current", + _previousSnapshotHash: "hash-previous", + }), + previousStateId: "state-backlog", + previousStateType: "backlog", + }), + expect.anything(), + ); + db.close(); + }); + + it("cancels every active run for an issue when the issue closes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-close-all-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const cancelRun = vi.fn(async () => {}); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: {}, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => ({ + ...issueFixture, + stateId: "state-done", + stateName: "Done", + stateType: "completed", + })), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => true), + findActiveRunForIssue: vi.fn(() => ({ id: "run-2", issueId: "issue-1" })), + createRun: vi.fn(), + cancelRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => [ + { id: "run-1", issueId: "issue-1" }, + { id: "run-2", issueId: "issue-1" }, + { id: "run-3", issueId: "issue-2" }, + ]), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.processIssueUpdate("issue-1"); + + expect(cancelRun).toHaveBeenCalledTimes(2); + expect(cancelRun).toHaveBeenNthCalledWith( + 1, + "run-1", + "Issue externally completed", + expect.objectContaining({ workflows: expect.any(Array) }), + ); + expect(cancelRun).toHaveBeenNthCalledWith( + 2, + "run-2", + "Issue externally completed", + expect.objectContaining({ workflows: expect.any(Array) }), + ); + db.close(); + }); + + it("forwards employee overrides through queue resolution", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-override-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => [{ id: "run-1", employeeOverride: "agent:worker-1" }]), + resolveRunAction, + getRunDetail: vi.fn(async () => null), + } as any, + autoStart: false, + }); + + await service.resolveQueueItem({ queueItemId: "run-1", action: "retry", employeeOverride: "agent:worker-1" }); + expect(resolveRunAction).toHaveBeenCalledWith("run-1", "retry", undefined, policy, "agent:worker-1"); + db.close(); + }); }); diff --git a/apps/desktop/src/main/services/cto/linearSyncService.ts b/apps/desktop/src/main/services/cto/linearSyncService.ts index 6fd47178f..a13143fe7 100644 --- a/apps/desktop/src/main/services/cto/linearSyncService.ts +++ b/apps/desktop/src/main/services/cto/linearSyncService.ts @@ -1,7 +1,9 @@ +import { randomUUID } from "node:crypto"; import type { CtoGetLinearWorkflowRunDetailArgs, CtoResolveLinearSyncQueueItemArgs, LinearSyncDashboard, + LinearSyncEventRecord, LinearSyncQueueItem, LinearWorkflowConfig, LinearWorkflowRunDetail, @@ -14,10 +16,21 @@ import type { LinearRoutingService } from "./linearRoutingService"; import type { LinearIntakeService } from "./linearIntakeService"; import type { LinearDispatcherService } from "./linearDispatcherService"; import type { IssueTracker } from "./issueTracker"; -import { getErrorMessage, nowIso } from "../shared/utils"; +import { getErrorMessage, nowIso, safeJsonParse } from "../shared/utils"; -function isIssueOpen(issue: NormalizedLinearIssue): boolean { - return issue.stateType !== "completed" && issue.stateType !== "canceled"; +const DEFAULT_TERMINAL_STATE_TYPES = ["completed", "canceled"] as const; + +function uniqueStrings(values: Array): string[] { + return Array.from(new Set(values.map((value) => value?.trim().toLowerCase() ?? "").filter(Boolean))); +} + +function getTerminalStateTypes(policy: LinearWorkflowConfig): string[] { + const values = uniqueStrings(policy.intake.terminalStateTypes ?? [...DEFAULT_TERMINAL_STATE_TYPES]); + return values.length ? values : [...DEFAULT_TERMINAL_STATE_TYPES]; +} + +function isIssueOpen(issue: NormalizedLinearIssue, policy: LinearWorkflowConfig): boolean { + return !new Set(getTerminalStateTypes(policy)).has((issue.stateType ?? "").trim().toLowerCase()); } function snapshotChanged(issue: NormalizedLinearIssue): boolean { @@ -51,6 +64,63 @@ export function createLinearSyncService(args: { let lastSkipReason: string | null = null; const reconciliationIntervalSec = Math.max(15, Math.floor(args.reconciliationIntervalSec ?? 30)); + const appendSyncEvent = (input: { + issueId?: string | null; + queueItemId?: string | null; + eventType: string; + status?: string | null; + message?: string | null; + payload?: Record | null; + }): void => { + args.db.run( + ` + insert into linear_sync_events(id, project_id, issue_id, queue_item_id, event_type, status, message, payload_json, created_at) + values(?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + randomUUID(), + args.projectId, + input.issueId ?? null, + input.queueItemId ?? null, + input.eventType, + input.status ?? null, + input.message ?? null, + input.payload ? JSON.stringify(input.payload) : null, + nowIso(), + ], + ); + }; + + const listRecentSyncEvents = (limit = 12): LinearSyncEventRecord[] => + args.db.all<{ + id: string; + issue_id: string | null; + queue_item_id: string | null; + event_type: string; + status: string | null; + message: string | null; + payload_json: string | null; + created_at: string; + }>( + ` + select id, issue_id, queue_item_id, event_type, status, message, payload_json, created_at + from linear_sync_events + where project_id = ? + order by datetime(created_at) desc + limit ? + `, + [args.projectId, Math.max(1, Math.floor(limit))], + ).map((row) => ({ + id: row.id, + issueId: row.issue_id, + queueItemId: row.queue_item_id, + eventType: row.event_type, + status: row.status, + message: row.message, + payload: safeJsonParse | null>(row.payload_json, null), + createdAt: row.created_at, + })); + const logSkip = (reason: "no_enabled_workflows" | "no_credentials", meta: Record) => { if (lastSkipReason === reason) return; lastSkipReason = reason; @@ -129,39 +199,106 @@ export function createLinearSyncService(args: { const dispatchNewRuns = async (policy: LinearWorkflowConfig) => { const candidates = await args.intakeService.fetchCandidates(policy); for (const issue of candidates) { - // Enforce per-workflow concurrency caps before each dispatch - const maxActive = policy.workflows.reduce((cap, wf) => { - const limit = wf.concurrency?.maxActiveRuns; - if (limit == null) return cap; - return cap == null ? limit : Math.max(cap, limit); - }, undefined); - if (maxActive != null) { - const activeCount = args.dispatcherService.listActiveRuns().length; - if (activeCount >= maxActive) break; - } await processIssueSnapshot(issue, policy); } }; const processIssueSnapshot = async (issue: NormalizedLinearIssue, policy: LinearWorkflowConfig): Promise => { args.intakeService.persistSnapshot(issue); - if (!isIssueOpen(issue)) { - const activeRun = args.dispatcherService.findActiveRunForIssue(issue.id); - if (activeRun) { + if (!isIssueOpen(issue, policy)) { + const activeRuns = args.dispatcherService.listActiveRuns().filter((run) => run.issueId === issue.id); + for (const activeRun of activeRuns) { await args.dispatcherService.cancelRun(activeRun.id, `Issue externally ${issue.stateType}`, policy); + appendSyncEvent({ + issueId: issue.id, + queueItemId: activeRun.id, + eventType: "issue_closed", + status: "cancelled", + message: `Issue is now ${issue.stateType}; cancelling the active workflow run.`, + }); } return; } - const activeRun = args.dispatcherService.findActiveRunForIssue(issue.id); - if (!activeRun && !snapshotChanged(issue)) return; - if (!activeRun) { - const match = await args.routingService.routeIssue({ issue, policy }); - if (!match.workflow) return; - const run = args.dispatcherService.createRun(issue, match); - await args.dispatcherService.advanceRun(run.id, policy); + const match = await args.routingService.routeIssue({ issue, policy }); + if (!match.workflow) return; + if (match.workflow.routing?.watchOnly) { + if (!snapshotChanged(issue)) return; + appendSyncEvent({ + issueId: issue.id, + eventType: "watch_only_match", + status: "observed", + message: `Observed '${issue.identifier}' with watch-only workflow '${match.workflow.name}'.`, + payload: { + workflowId: match.workflow.id, + workflowName: match.workflow.name, + reason: match.reason, + matchedSignals: match.candidates.find((candidate) => candidate.workflowId === match.workflow?.id)?.matchedSignals ?? [], + }, + }); + return; + } + if (!snapshotChanged(issue)) return; + const activeRuns = args.dispatcherService.listActiveRuns(); + const workflowActiveRuns = activeRuns.filter((run) => run.workflowId === match.workflow!.id); + const issueWorkflowRuns = workflowActiveRuns.filter((run) => run.issueId === issue.id); + const dedupeByIssue = match.workflow.concurrency?.dedupeByIssue !== false; + if (dedupeByIssue && issueWorkflowRuns.length > 0) { + appendSyncEvent({ + issueId: issue.id, + eventType: "issue_deduped", + status: "deferred", + message: `Skipped duplicate run for '${issue.identifier}' in workflow '${match.workflow.name}'.`, + payload: { + workflowId: match.workflow.id, + activeRunIds: issueWorkflowRuns.map((run) => run.id), + }, + }); + return; + } + const maxActiveRuns = match.workflow.concurrency?.maxActiveRuns; + if (maxActiveRuns != null && workflowActiveRuns.length >= maxActiveRuns) { + appendSyncEvent({ + issueId: issue.id, + eventType: "workflow_capacity_wait", + status: "deferred", + message: `Workflow '${match.workflow.name}' is at capacity.`, + payload: { + workflowId: match.workflow.id, + activeRuns: workflowActiveRuns.length, + maxActiveRuns, + }, + }); return; } - await args.dispatcherService.advanceRun(activeRun.id, policy); + const perIssue = match.workflow.concurrency?.perIssue; + if (perIssue != null && issueWorkflowRuns.length >= perIssue) { + appendSyncEvent({ + issueId: issue.id, + eventType: "issue_per_workflow_limit", + status: "deferred", + message: `Issue '${issue.identifier}' already has ${issueWorkflowRuns.length} active run(s) for '${match.workflow.name}'.`, + payload: { + workflowId: match.workflow.id, + activeRuns: issueWorkflowRuns.length, + perIssue, + }, + }); + return; + } + const run = args.dispatcherService.createRun(issue, match); + appendSyncEvent({ + issueId: issue.id, + queueItemId: run.id, + eventType: "run_created", + status: "queued", + message: `Queued workflow '${match.workflow.name}' for '${issue.identifier}'.`, + payload: { + workflowId: match.workflow.id, + workflowName: match.workflow.name, + reason: match.reason, + }, + }); + await args.dispatcherService.advanceRun(run.id, policy); }; const advanceRuns = async (policy: LinearWorkflowConfig) => { @@ -247,9 +384,9 @@ export function createLinearSyncService(args: { } const issue = await args.issueTracker.fetchIssueById(issueId); if (!issue) return; - const previousSnapshotRow = args.db.get<{ payload_json: string | null }>( + const previousSnapshotRow = args.db.get<{ payload_json: string | null; hash: string | null }>( ` - select payload_json + select payload_json, hash from linear_issue_snapshots where project_id = ? and issue_id = ? @@ -258,13 +395,18 @@ export function createLinearSyncService(args: { [args.projectId, issueId] ); const previousIssue = previousSnapshotRow?.payload_json - ? JSON.parse(previousSnapshotRow.payload_json) as Partial + ? safeJsonParse | null>(previousSnapshotRow.payload_json, null) : null; const issueWithHistory: NormalizedLinearIssue = { ...issue, previousStateId: previousIssue?.stateId ?? null, previousStateName: previousIssue?.stateName ?? null, previousStateType: previousIssue?.stateType ?? null, + raw: { + ...(issue.raw ?? {}), + _snapshotHash: args.intakeService.issueHash(issue), + _previousSnapshotHash: previousSnapshotRow?.hash ?? null, + }, }; const policy = args.flowPolicyService.getPolicy(); const workflowsEnabled = workflowEnabled(policy); @@ -329,6 +471,17 @@ export function createLinearSyncService(args: { }, { queued: 0, retryWaiting: 0, escalated: 0, dispatched: 0, failed: 0 } ); + const watchOnlyHits = Number( + args.db.get<{ total: number }>( + ` + select count(*) as total + from linear_sync_events + where project_id = ? + and event_type = 'watch_only_match' + `, + [args.projectId], + )?.total ?? 0, + ); return { enabled: Boolean(state?.enabled ?? 0), @@ -340,6 +493,8 @@ export function createLinearSyncService(args: { lastError: state?.last_error ?? null, queue: counts, claimsActive: counts.queued + counts.retryWaiting + counts.escalated + counts.dispatched, + watchOnlyHits, + recentEvents: listRecentSyncEvents(), }; }; @@ -354,7 +509,13 @@ export function createLinearSyncService(args: { const resolveQueueItem = async (input: CtoResolveLinearSyncQueueItemArgs): Promise => { const policy = args.flowPolicyService.getPolicy(); - const run = await args.dispatcherService.resolveRunAction(input.queueItemId, input.action, input.note, policy); + const run = await args.dispatcherService.resolveRunAction( + input.queueItemId, + input.action, + input.note, + policy, + input.employeeOverride, + ); if (!run) return null; if (run.status !== "completed" && run.status !== "failed" && run.status !== "cancelled") { await args.dispatcherService.advanceRun(run.id, policy); diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts index cbe6747d8..cb7658659 100644 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts +++ b/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts @@ -18,6 +18,8 @@ describe("linearWorkflowFileService", () => { expect(loaded.source).toBe("generated"); expect(loaded.migration?.needsSave).toBe(true); + expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); expect(loaded.settings.ctoLinearAssigneeName).toBe("CTO"); expect(loaded.workflows.map((workflow) => workflow.id)).toEqual([ "cto-mission-autopilot", @@ -99,6 +101,9 @@ describe("linearWorkflowFileService", () => { expect(loaded.source).toBe("generated"); expect(loaded.migration?.hasLegacyConfig).toBe(true); expect(loaded.migration?.needsSave).toBe(true); + expect(loaded.intake.projectSlugs).toEqual(["acme-platform"]); + expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); expect(migrated?.target.type).toBe("mission"); expect(migrated?.target.missionTemplate).toBe("fast-track"); expect(migrated?.target.workerSelector).toEqual({ mode: "slug", value: "backend-hotfix" }); diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts index a63bdddde..7ec437699 100644 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts +++ b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts @@ -7,6 +7,7 @@ import type { LinearWorkflowConfig, LinearWorkflowConfigFileMeta, LinearWorkflowDefinition, + LinearWorkflowIntake, LinearWorkflowSettings, LinearWorkflowWorkerSelector, } from "../../../shared/types"; @@ -17,6 +18,8 @@ import { isRecord } from "../shared/utils"; const WORKFLOW_VERSION = 1 as const; const SETTINGS_FILE = "_settings.yaml"; const LEGACY_SNAPSHOT_FILE = "legacy-linear-sync.snapshot.json"; +const DEFAULT_ACTIVE_STATE_TYPES = ["backlog", "unstarted", "started"] as const; +const DEFAULT_TERMINAL_STATE_TYPES = ["completed", "canceled"] as const; function hashContent(content: string): string { return createHash("sha256").update(content).digest("hex"); @@ -38,36 +41,94 @@ function slugify(value: string): string { .replace(/^-+|-+$/g, "") || "workflow"; } -function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDefinition | null { +function normalizeIntake(input: unknown): LinearWorkflowIntake { + const source = isRecord(input) ? input : {}; + return { + ...(ensureStringArray(source.projectSlugs).length + ? { projectSlugs: ensureStringArray(source.projectSlugs) } + : {}), + ...(ensureStringArray(source.activeStateTypes).length + ? { activeStateTypes: ensureStringArray(source.activeStateTypes) } + : { activeStateTypes: [...DEFAULT_ACTIVE_STATE_TYPES] }), + ...(ensureStringArray(source.terminalStateTypes).length + ? { terminalStateTypes: ensureStringArray(source.terminalStateTypes) } + : { terminalStateTypes: [...DEFAULT_TERMINAL_STATE_TYPES] }), + }; +} + +function normalizeWorkerSelector(input: unknown): LinearWorkflowWorkerSelector | undefined { + const workerSelector = isRecord(input) ? input : null; + if (!workerSelector) return undefined; + if ( + workerSelector.mode !== "id" + && workerSelector.mode !== "slug" + && workerSelector.mode !== "capability" + && workerSelector.mode !== "none" + ) { + return undefined; + } + if (workerSelector.mode === "none") return { mode: "none" }; + if (typeof workerSelector.value !== "string" || workerSelector.value.trim().length === 0) return undefined; + return { + mode: workerSelector.mode, + value: workerSelector.value.trim(), + } as LinearWorkflowWorkerSelector; +} + +function normalizeTarget(input: unknown): LinearWorkflowDefinition["target"] | null { if (!isRecord(input)) return null; - const id = typeof input.id === "string" && input.id.trim().length ? input.id.trim() : fallbackId; - const name = typeof input.name === "string" && input.name.trim().length ? input.name.trim() : id; - if (!isRecord(input.target) || !isRecord(input.triggers)) return null; - const targetType = input.target.type; + const targetType = input.type; if ( - targetType !== "mission" && - targetType !== "employee_session" && - targetType !== "worker_run" && - targetType !== "pr_resolution" && - targetType !== "review_gate" + targetType !== "mission" + && targetType !== "employee_session" + && targetType !== "worker_run" + && targetType !== "pr_resolution" + && targetType !== "review_gate" ) { return null; } - const workerSelector = isRecord(input.target.workerSelector) - ? input.target.workerSelector - : null; - const normalizedSelector: LinearWorkflowWorkerSelector | undefined = workerSelector - && (workerSelector.mode === "id" || workerSelector.mode === "slug" || workerSelector.mode === "capability" || workerSelector.mode === "none") - ? workerSelector.mode === "none" - ? { mode: "none" } - : typeof workerSelector.value === "string" && workerSelector.value.trim().length - ? { - mode: workerSelector.mode, - value: workerSelector.value.trim(), - } as LinearWorkflowWorkerSelector - : undefined - : undefined; + const normalizedSelector = normalizeWorkerSelector(input.workerSelector); + + const downstreamTarget = normalizeTarget(input.downstreamTarget); + + return { + type: targetType, + ...(normalizedSelector ? { workerSelector: normalizedSelector } : {}), + ...(typeof input.employeeIdentityKey === "string" && input.employeeIdentityKey.trim().length + ? { employeeIdentityKey: input.employeeIdentityKey.trim() as LinearWorkflowDefinition["target"]["employeeIdentityKey"] } + : {}), + ...(typeof input.sessionTemplate === "string" ? { sessionTemplate: input.sessionTemplate } : {}), + ...(typeof input.missionTemplate === "string" ? { missionTemplate: input.missionTemplate } : {}), + ...(input.executorKind === "cto" || input.executorKind === "employee" || input.executorKind === "worker" + ? { executorKind: input.executorKind } + : {}), + ...(input.runMode === "autopilot" || input.runMode === "assisted" || input.runMode === "manual" + ? { runMode: input.runMode } + : {}), + ...(input.prTiming === "after_start" || input.prTiming === "after_target_complete" || input.prTiming === "none" + ? { prTiming: input.prTiming } + : {}), + ...(input.laneSelection === "primary" || input.laneSelection === "fresh_issue_lane" || input.laneSelection === "operator_prompt" + ? { laneSelection: input.laneSelection } + : {}), + ...(input.sessionReuse === "reuse_existing" || input.sessionReuse === "fresh_session" + ? { sessionReuse: input.sessionReuse } + : {}), + ...(typeof input.freshLaneName === "string" ? { freshLaneName: input.freshLaneName } : {}), + ...(typeof input.phaseProfile === "string" ? { phaseProfile: input.phaseProfile } : {}), + ...(isRecord(input.prStrategy) ? { prStrategy: input.prStrategy as LinearWorkflowDefinition["target"]["prStrategy"] } : {}), + ...(downstreamTarget ? { downstreamTarget } : {}), + }; +} + +function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDefinition | null { + if (!isRecord(input)) return null; + const id = typeof input.id === "string" && input.id.trim().length ? input.id.trim() : fallbackId; + const name = typeof input.name === "string" && input.name.trim().length ? input.name.trim() : id; + if (!isRecord(input.target) || !isRecord(input.triggers)) return null; + const target = normalizeTarget(input.target); + if (!target) return null; return { id, @@ -102,35 +163,10 @@ function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDe ...(ensureStringArray(input.routing.metadataTags).length ? { metadataTags: ensureStringArray(input.routing.metadataTags) } : {}), + ...(typeof input.routing.watchOnly === "boolean" ? { watchOnly: input.routing.watchOnly } : {}), } : undefined, - target: { - type: targetType, - ...(normalizedSelector ? { workerSelector: normalizedSelector } : {}), - ...(typeof input.target.employeeIdentityKey === "string" && input.target.employeeIdentityKey.trim().length - ? { employeeIdentityKey: input.target.employeeIdentityKey.trim() as LinearWorkflowDefinition["target"]["employeeIdentityKey"] } - : {}), - ...(typeof input.target.sessionTemplate === "string" ? { sessionTemplate: input.target.sessionTemplate } : {}), - ...(typeof input.target.missionTemplate === "string" ? { missionTemplate: input.target.missionTemplate } : {}), - ...(input.target.executorKind === "cto" || input.target.executorKind === "employee" || input.target.executorKind === "worker" - ? { executorKind: input.target.executorKind } - : {}), - ...(input.target.runMode === "autopilot" || input.target.runMode === "assisted" || input.target.runMode === "manual" - ? { runMode: input.target.runMode } - : {}), - ...(input.target.prTiming === "after_start" || input.target.prTiming === "after_target_complete" || input.target.prTiming === "none" - ? { prTiming: input.target.prTiming } - : {}), - ...(input.target.laneSelection === "primary" || input.target.laneSelection === "fresh_issue_lane" - ? { laneSelection: input.target.laneSelection } - : {}), - ...(input.target.sessionReuse === "reuse_existing" || input.target.sessionReuse === "fresh_session" - ? { sessionReuse: input.target.sessionReuse } - : {}), - ...(typeof input.target.freshLaneName === "string" ? { freshLaneName: input.target.freshLaneName } : {}), - ...(typeof input.target.phaseProfile === "string" ? { phaseProfile: input.target.phaseProfile } : {}), - ...(isRecord(input.target.prStrategy) ? { prStrategy: input.target.prStrategy as LinearWorkflowDefinition["target"]["prStrategy"] } : {}), - }, + target, steps: Array.isArray(input.steps) ? input.steps .filter(isRecord) @@ -159,7 +195,9 @@ function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDe ...(typeof input.closeout.failureState === "string" ? { failureState: input.closeout.failureState } : {}), ...(typeof input.closeout.successComment === "string" ? { successComment: input.closeout.successComment } : {}), ...(typeof input.closeout.failureComment === "string" ? { failureComment: input.closeout.failureComment } : {}), + ...(typeof input.closeout.commentTemplate === "string" ? { commentTemplate: input.closeout.commentTemplate } : {}), ...(ensureStringArray(input.closeout.applyLabels).length ? { applyLabels: ensureStringArray(input.closeout.applyLabels) } : {}), + ...(ensureStringArray(input.closeout.labels).length ? { labels: ensureStringArray(input.closeout.labels) } : {}), ...(typeof input.closeout.reopenOnFailure === "boolean" ? { reopenOnFailure: input.closeout.reopenOnFailure } : {}), ...(typeof input.closeout.resolveOnSuccess === "boolean" ? { resolveOnSuccess: input.closeout.resolveOnSuccess } : {}), ...(input.closeout.reviewReadyWhen === "work_complete" || input.closeout.reviewReadyWhen === "pr_created" || input.closeout.reviewReadyWhen === "pr_ready" @@ -179,6 +217,7 @@ function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDe ? { ...(Number.isFinite(Number(input.retry.maxAttempts)) ? { maxAttempts: Math.max(0, Math.floor(Number(input.retry.maxAttempts))) } : {}), ...(Number.isFinite(Number(input.retry.baseDelaySec)) ? { baseDelaySec: Math.max(5, Math.floor(Number(input.retry.baseDelaySec))) } : {}), + ...(Number.isFinite(Number(input.retry.backoffSeconds)) ? { backoffSeconds: Math.max(5, Math.floor(Number(input.retry.backoffSeconds))) } : {}), } : undefined, concurrency: isRecord(input.concurrency) @@ -189,6 +228,7 @@ function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDe ...(Number.isFinite(Number(input.concurrency.perIssue)) ? { perIssue: Math.max(1, Math.floor(Number(input.concurrency.perIssue))) } : {}), + ...(typeof input.concurrency.dedupeByIssue === "boolean" ? { dedupeByIssue: input.concurrency.dedupeByIssue } : {}), } : undefined, observability: isRecord(input.observability) @@ -213,6 +253,7 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea const base = createDefaultLinearWorkflowConfig(); return { ...base, + intake: normalizeIntake(null), workflows: [ baseWorkflow, { @@ -353,6 +394,11 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea return { version: WORKFLOW_VERSION, source: "generated", + intake: { + projectSlugs: projects.map((project) => project.slug).filter(Boolean), + activeStateTypes: [...DEFAULT_ACTIVE_STATE_TYPES], + terminalStateTypes: [...DEFAULT_TERMINAL_STATE_TYPES], + }, settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, workflows, files: [], @@ -383,17 +429,25 @@ export function createLinearWorkflowFileService(args: { .map((entry) => path.join(workflowDir, entry)); }; - const readSettings = (): LinearWorkflowSettings => { - if (!fs.existsSync(settingsPath)) return {}; + const readSettings = (): { settings: LinearWorkflowSettings; intake: LinearWorkflowIntake } => { + if (!fs.existsSync(settingsPath)) { + return { + settings: {}, + intake: normalizeIntake(null), + }; + } const raw = fs.readFileSync(settingsPath, "utf8"); const parsed = YAML.parse(raw); - if (!isRecord(parsed)) return {}; + if (!isRecord(parsed)) return { settings: {}, intake: normalizeIntake(null) }; return { - ...(typeof parsed.ctoLinearAssigneeId === "string" ? { ctoLinearAssigneeId: parsed.ctoLinearAssigneeId } : {}), - ...(typeof parsed.ctoLinearAssigneeName === "string" ? { ctoLinearAssigneeName: parsed.ctoLinearAssigneeName } : {}), - ...(ensureStringArray(parsed.ctoLinearAssigneeAliases).length - ? { ctoLinearAssigneeAliases: ensureStringArray(parsed.ctoLinearAssigneeAliases) } - : {}), + settings: { + ...(typeof parsed.ctoLinearAssigneeId === "string" ? { ctoLinearAssigneeId: parsed.ctoLinearAssigneeId } : {}), + ...(typeof parsed.ctoLinearAssigneeName === "string" ? { ctoLinearAssigneeName: parsed.ctoLinearAssigneeName } : {}), + ...(ensureStringArray(parsed.ctoLinearAssigneeAliases).length + ? { ctoLinearAssigneeAliases: ensureStringArray(parsed.ctoLinearAssigneeAliases) } + : {}), + }, + intake: normalizeIntake(parsed.intake), }; }; @@ -401,6 +455,20 @@ export function createLinearWorkflowFileService(args: { const files = listWorkflowFiles(); const workflowFiles = files.filter((filePath) => path.basename(filePath) !== SETTINGS_FILE); if (!workflowFiles.length) { + if (fs.existsSync(settingsPath)) { + const settingsDoc = readSettings(); + const generated = migrateLegacyConfig(legacyConfig); + return { + ...generated, + intake: settingsDoc.intake, + settings: settingsDoc.settings, + migration: { + hasLegacyConfig: generated.migration?.hasLegacyConfig === true, + needsSave: generated.migration?.needsSave === true, + compatibilitySnapshotPath: legacyConfig ? legacySnapshotPath : null, + }, + }; + } const generated = migrateLegacyConfig(legacyConfig); return { ...generated, @@ -412,7 +480,7 @@ export function createLinearWorkflowFileService(args: { }; } - const settings = readSettings(); + const settingsDoc = readSettings(); const workflows = workflowFiles .map((filePath, index) => { const raw = fs.readFileSync(filePath, "utf8"); @@ -453,7 +521,8 @@ export function createLinearWorkflowFileService(args: { return { version: WORKFLOW_VERSION, source: "repo", - settings, + intake: settingsDoc.intake, + settings: settingsDoc.settings, workflows, files: fileEntries, migration: { @@ -476,6 +545,7 @@ export function createLinearWorkflowFileService(args: { ctoLinearAssigneeId: config.settings.ctoLinearAssigneeId ?? null, ctoLinearAssigneeName: config.settings.ctoLinearAssigneeName ?? "CTO", ctoLinearAssigneeAliases: config.settings.ctoLinearAssigneeAliases ?? ["cto"], + intake: normalizeIntake(config.intake), }, { indent: 2 }); fs.writeFileSync(settingsPath, settingsYaml, "utf8"); nextWorkflowPaths.add(settingsPath); diff --git a/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts b/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts index 6757eaf0c..84d1d713c 100644 --- a/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts +++ b/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts @@ -66,7 +66,11 @@ describe("openclawBridgeService", () => { projectRoot: "/tmp/project", adeDir, laneService: { - list: vi.fn(async () => [{ id: "lane-1" }]), + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [ + { id: "lane-2", laneType: "feature" }, + { id: "lane-1", laneType: "primary" }, + ]), } as any, agentChatService, ctoStateService: { @@ -137,7 +141,10 @@ describe("openclawBridgeService", () => { service = createOpenclawBridgeService({ projectRoot: "/tmp/project", adeDir, - laneService: { list: vi.fn(async () => [{ id: "lane-1" }]) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, agentChatService: { listSessions: vi.fn(async () => []), ensureIdentitySession, @@ -171,6 +178,12 @@ describe("openclawBridgeService", () => { }), }); expect(good.status).toBe(200); + await expect(good.json()).resolves.toEqual(expect.objectContaining({ + accepted: true, + async: true, + status: "working", + routeTarget: "agent:frontend", + })); expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "agent:worker-1" })); const fallback = await fetch(state.endpoints.queryUrl!, { @@ -211,7 +224,10 @@ describe("openclawBridgeService", () => { service = createOpenclawBridgeService({ projectRoot: "/tmp/project", adeDir, - laneService: { list: vi.fn(async () => [{ id: "lane-1" }]) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, agentChatService: { listSessions: vi.fn(async () => []), ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), @@ -261,7 +277,10 @@ describe("openclawBridgeService", () => { const service = createOpenclawBridgeService({ projectRoot: "/tmp/project", adeDir, - laneService: { list: vi.fn(async () => [{ id: "lane-1" }]) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, agentChatService: { listSessions: vi.fn(async () => []), ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), @@ -287,4 +306,171 @@ describe("openclawBridgeService", () => { expect(service.getState().status.queuedMessages).toBe(1); expect(record.context).toEqual({ lane: "lane-1" }); }); + + it("recursively redacts inbound bridge context before prompting and persistence", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-redact-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + let service!: ReturnType; + const sentMessages: Array<{ text: string }> = []; + service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { + sentMessages.push({ text }); + queueMicrotask(() => { + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "user_message", text: displayText ?? text, turnId: "turn-1" }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: "redacted", turnId: "turn-1" }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "done", turnId: "turn-1", status: "completed" }, + }); + }); + }), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const res = await fetch(service.getState().endpoints.queryUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify({ + requestId: "req-redact-1", + message: "Review this", + context: { + nested: { + apiKey: "test-api-key-placeholder", + note: "safe", + }, + secret: "remove-me", + }, + }), + }); + + expect(res.status).toBe(200); + expect(sentMessages[0]?.text).toContain("\"apiKey\": \"[REDACTED]\""); + expect(sentMessages[0]?.text).toContain("\"note\": \"safe\""); + expect(sentMessages[0]?.text).not.toContain("remove-me"); + const inbound = service.listMessages(10).find((entry) => entry.requestId === "req-redact-1" && entry.direction === "inbound"); + expect(inbound?.context).toEqual({ + nested: { + apiKey: "[REDACTED]", + note: "safe", + }, + }); + }); + + it("keeps shareMode full while still redacting sensitive values", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-full-share-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + const service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async () => {}), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "full", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const record = await service.sendMessage({ + requestId: "queued-message-2", + agentId: "discord-cto", + message: "Mission finished", + context: { + secret: "Bearer very-secret-token-value", + lane: "lane-1", + }, + }); + + expect(record.context).toEqual({ + secret: "[REDACTED]", + lane: "lane-1", + }); + }); + + it("migrates legacy runtime files into cache and removes repo-visible copies", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-migrate-")); + writeOpenclawConfig(adeDir, { enabled: false }); + fs.mkdirSync(path.join(adeDir, "cto"), { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "cto", "openclaw-history.json"), + JSON.stringify([{ + id: "legacy-1", + requestId: "legacy-request", + direction: "inbound", + mode: "hook", + status: "received", + body: "Legacy body", + summary: "Legacy summary", + context: { + apiKey: "test-api-key-placeholder", + }, + createdAt: new Date().toISOString(), + }], null, 2), + "utf8", + ); + + const service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async () => {}), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + expect(fs.existsSync(path.join(adeDir, "cto", "openclaw-history.json"))).toBe(false); + expect(fs.existsSync(path.join(adeDir, "cache", "openclaw", "openclaw-history.json"))).toBe(true); + expect(service.listMessages(10)[0]?.context).toEqual({ apiKey: "[REDACTED]" }); + }); }); diff --git a/apps/desktop/src/main/services/cto/openclawBridgeService.ts b/apps/desktop/src/main/services/cto/openclawBridgeService.ts index 639fafd90..af049b729 100644 --- a/apps/desktop/src/main/services/cto/openclawBridgeService.ts +++ b/apps/desktop/src/main/services/cto/openclawBridgeService.ts @@ -26,7 +26,16 @@ import type { TestEvent, OrchestratorRuntimeEvent, } from "../../../shared/types"; -import { getErrorMessage, nowIso, parseIsoToEpoch, stableStringify, toBase64Url, writeTextAtomic } from "../shared/utils"; +import { + clipText, + getErrorMessage, + isRecord, + nowIso, + parseIsoToEpoch, + sanitizeStructuredData, + toBase64Url, + writeTextAtomic, +} from "../shared/utils"; const DEFAULT_BRIDGE_PORT = 18791; const HTTP_BODY_LIMIT_BYTES = 1_000_000; @@ -39,6 +48,16 @@ const CONNECT_CHALLENGE_TIMEOUT_MS = 2_000; const TICK_WATCH_FLOOR_MS = 1_000; const DEFAULT_TICK_INTERVAL_MS = 30_000; const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +const BRIDGE_CONTEXT_MAX_STRING_LENGTH = 4_000; +const BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES = 50; +const BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES = 50; +const HISTORY_BODY_MAX_LENGTH = 1_200; +const HISTORY_ERROR_MAX_LENGTH = 400; +const HISTORY_SUMMARY_MAX_LENGTH = 160; +const OPENCLAW_HISTORY_FILE = "openclaw-history.json"; +const OPENCLAW_OUTBOX_FILE = "openclaw-outbox.json"; +const OPENCLAW_IDEMPOTENCY_FILE = "openclaw-idempotency.json"; +const OPENCLAW_ROUTES_FILE = "openclaw-routes.json"; type DeviceIdentity = { deviceId: string; @@ -134,11 +153,6 @@ type OpenclawBridgeServiceArgs = { onStatusChange?: (status: OpenclawBridgeStatus) => void; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - - function trimToNull(value: unknown): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed.length ? trimmed : null; @@ -150,9 +164,16 @@ function summarizeMessage(text: string, maxLength = 120): string { return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; } -function sanitizeContext(context: unknown): Record | null { - if (!isRecord(context)) return null; - return JSON.parse(stableStringify(context)) as Record; +function sanitizeContext( + context: unknown, + options?: { blockedTopLevelKeys?: Iterable }, +): Record | null { + return sanitizeStructuredData(context, { + blockedTopLevelKeys: options?.blockedTopLevelKeys, + maxStringLength: BRIDGE_CONTEXT_MAX_STRING_LENGTH, + maxObjectEntries: BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES, + maxArrayEntries: BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES, + }); } function buildDeviceAuthPayloadV3(params: { @@ -248,21 +269,6 @@ function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { return toBase64Url(derivePublicKeyRaw(publicKeyPem)); } -function defaultConfig(): OpenclawBridgeConfig { - return { - enabled: false, - bridgePort: DEFAULT_BRIDGE_PORT, - gatewayUrl: null, - gatewayToken: null, - deviceToken: null, - hooksToken: null, - allowedAgentIds: [], - defaultTarget: "cto", - allowEmployeeTargets: true, - notificationRoutes: [], - }; -} - function normalizeNotificationRoute(value: unknown): OpenclawNotificationRoute | null { if (!isRecord(value)) return null; const notificationType = trimToNull(value.notificationType); @@ -395,17 +401,65 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { const logger = args.logger ?? null; const secretPath = path.join(args.adeDir, "local.secret.yaml"); const ctoDir = path.join(args.adeDir, "cto"); + const cacheDir = path.join(args.adeDir, "cache", "openclaw"); const devicePath = path.join(ctoDir, "openclaw-device.json"); - const historyPath = path.join(ctoDir, "openclaw-history.json"); - const outboxPath = path.join(ctoDir, "openclaw-outbox.json"); - const idempotencyPath = path.join(ctoDir, "openclaw-idempotency.json"); - const routeCachePath = path.join(ctoDir, "openclaw-routes.json"); + const historyPath = path.join(cacheDir, OPENCLAW_HISTORY_FILE); + const outboxPath = path.join(cacheDir, OPENCLAW_OUTBOX_FILE); + const idempotencyPath = path.join(cacheDir, OPENCLAW_IDEMPOTENCY_FILE); + const routeCachePath = path.join(cacheDir, OPENCLAW_ROUTES_FILE); fs.mkdirSync(ctoDir, { recursive: true }); + fs.mkdirSync(cacheDir, { recursive: true }); + + const migrateLegacyRuntimeFile = (legacyFileName: string, nextPath: string): void => { + const legacyPath = path.join(ctoDir, legacyFileName); + if (!fs.existsSync(legacyPath)) return; + let copied = false; + try { + if (!fs.existsSync(nextPath)) { + writeTextAtomic(nextPath, fs.readFileSync(legacyPath, "utf8")); + copied = true; + } + } catch (error) { + logger?.warn("openclaw.runtime_state_migration_failed", { + legacyPath, + nextPath, + error: getErrorMessage(error), + }); + return; + } + if (!copied) return; + try { + fs.unlinkSync(legacyPath); + } catch (error) { + logger?.warn("openclaw.runtime_state_cleanup_failed", { + legacyPath, + error: getErrorMessage(error), + }); + } + }; + + migrateLegacyRuntimeFile(OPENCLAW_HISTORY_FILE, historyPath); + migrateLegacyRuntimeFile(OPENCLAW_OUTBOX_FILE, outboxPath); + migrateLegacyRuntimeFile(OPENCLAW_IDEMPOTENCY_FILE, idempotencyPath); + migrateLegacyRuntimeFile(OPENCLAW_ROUTES_FILE, routeCachePath); const deviceIdentity = loadOrCreateDeviceIdentity(devicePath); let config = readConfig(); - let history = readJsonFile(historyPath, []); - let outbox = readJsonFile(outboxPath, []); + let history: OpenclawMessageRecord[] = readJsonFile(historyPath, []).map((record): OpenclawMessageRecord => ({ + ...record, + body: clipText(String(record.body ?? ""), HISTORY_BODY_MAX_LENGTH), + summary: summarizeMessage(String(record.summary ?? record.body ?? ""), HISTORY_SUMMARY_MAX_LENGTH), + ...(sanitizeContext(record.context) ? { context: sanitizeContext(record.context) } : { context: null }), + ...(sanitizeContext(record.metadata) ? { metadata: sanitizeContext(record.metadata) } : { metadata: null }), + ...(typeof record.error === "string" ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), + })); + let outbox: OutboxEntry[] = readJsonFile(outboxPath, []).map((entry): OutboxEntry => ({ + ...entry, + envelope: { + ...entry.envelope, + context: sanitizeContext(entry.envelope.context) ?? null, + }, + })); let idempotencyState = pruneIdempotencyState(readJsonFile(idempotencyPath, {})); let routeCache = readJsonFile(routeCachePath, { byAgentId: {} }); @@ -416,7 +470,6 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { let wsConnectTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempt = 0; - let lastSeq: number | null = null; let tickTimer: ReturnType | null = null; let lastTickAt: number | null = null; let requestedStop = false; @@ -523,12 +576,20 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { } function saveHistoryRecord(record: OpenclawMessageRecord): OpenclawMessageRecord { - history = [...history.filter((entry) => entry.id !== record.id), record] + const sanitizedRecord: OpenclawMessageRecord = { + ...record, + body: clipText(record.body, HISTORY_BODY_MAX_LENGTH), + summary: summarizeMessage(record.summary || record.body, HISTORY_SUMMARY_MAX_LENGTH), + context: sanitizeContext(record.context), + ...(record.error ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), + ...(record.metadata ? { metadata: sanitizeContext(record.metadata) } : {}), + }; + history = [...history.filter((entry) => entry.id !== sanitizedRecord.id), sanitizedRecord] .sort((a, b) => parseIsoToEpoch(a.createdAt) - parseIsoToEpoch(b.createdAt)) .slice(-HISTORY_CAP); persistRuntimeState(); - setStatus({ lastMessageAt: record.createdAt }); - return record; + setStatus({ lastMessageAt: sanitizedRecord.createdAt }); + return sanitizedRecord; } function getHistoryMessages(limit = 40): OpenclawMessageRecord[] { @@ -582,18 +643,14 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { return fallbackMessage?.trim() || "No reply was generated."; } - async function resolveLaneId(): Promise { - const sessions = await args.agentChatService.listSessions(); - const mostRecent = sessions - .slice() - .sort((a, b) => parseIsoToEpoch(b.lastActivityAt) - parseIsoToEpoch(a.lastActivityAt))[0]; - if (mostRecent?.laneId) return mostRecent.laneId; - const lanes = await args.laneService.list({ includeArchived: false }); - const laneId = lanes[0]?.id ?? null; - if (!laneId) { + async function resolvePrimaryLaneId(): Promise { + await args.laneService.ensurePrimaryLane().catch(() => {}); + const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); + const preferred = lanes.find((entry) => entry.laneType === "primary") ?? lanes[0] ?? null; + if (!preferred?.id) { throw new Error("No lane is available to host the OpenClaw bridge session."); } - return laneId; + return preferred.id; } function resolveTarget(targetHint?: OpenclawTargetHint | null): { identityKey: "cto" | `agent:${string}`; resolvedTarget: OpenclawTargetHint; fallbackReason?: string } { @@ -625,15 +682,10 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { } function applyContextPolicy(context: Record | null | undefined): Record | null { - const safe = sanitizeContext(context); - if (!safe) return null; const policy = normalizeContextPolicy(args.ctoStateService?.getIdentity().openclawContextPolicy); - if (policy.shareMode === "full") return safe; - const blocked = new Set(policy.blockedCategories.map((entry) => entry.toLowerCase())); - if (!blocked.size) return safe; - return Object.fromEntries( - Object.entries(safe).filter(([key]) => !blocked.has(key.toLowerCase())), - ); + return sanitizeContext(context, { + blockedTopLevelKeys: policy.shareMode === "full" ? [] : policy.blockedCategories, + }); } function buildPromptFromInbound( @@ -665,7 +717,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { routeTarget: OpenclawTargetHint; fallbackReason?: string; }> { - const laneId = await resolveLaneId(); + const laneId = await resolvePrimaryLaneId(); const resolved = resolveTarget(targetHint); const session = await args.agentChatService.ensureIdentitySession({ identityKey: resolved.identityKey, @@ -704,6 +756,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { const message = filteredContext ? `${envelope.message.trim()}\n\n[filtered_context]\n${JSON.stringify(filteredContext, null, 2)}` : envelope.message.trim(); + const historyBody = envelope.message.trim(); const recordBase: OpenclawMessageRecord = { id: randomUUID(), requestId, @@ -712,8 +765,8 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { status: "queued", agentId: envelope.agentId ?? null, sessionKey: envelope.sessionKey ?? null, - body: message, - summary: summarizeMessage(message), + body: historyBody, + summary: summarizeMessage(historyBody), context: filteredContext, createdAt: nowIso(), metadata: envelope.notificationType ? { notificationType: envelope.notificationType } : undefined, @@ -806,7 +859,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { sessionKey: entry.envelope.sessionKey ?? null, body: entry.envelope.message, summary: summarizeMessage(entry.envelope.message), - context: sanitizeContext(entry.envelope.context), + context: applyContextPolicy(entry.envelope.context), createdAt: nowIso(), error: entry.lastError ?? "Outbox attempts exhausted.", }); @@ -919,6 +972,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { throw new Error("OpenClaw inbound message is required."); } const requestId = trimToNull(envelope.requestId) ?? trimToNull(envelope.idempotencyKey) ?? randomUUID(); + const normalizedContext = applyContextPolicy(envelope.context); if (hasSeenIdempotency(requestId)) { saveHistoryRecord({ id: randomUUID(), @@ -931,7 +985,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { targetHint: envelope.targetHint ?? null, body: message, summary: summarizeMessage(message), - context: sanitizeContext(envelope.context), + context: normalizedContext, createdAt: nowIso(), }); return { requestId, sessionId: "", routeTarget: config.defaultTarget, duplicate: true }; @@ -944,7 +998,6 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { } markIdempotency(requestId); - const normalizedContext = sanitizeContext(envelope.context); const targetSession = await ensureTargetSession(envelope.targetHint ?? config.defaultTarget); const route: ConversationRoute = { agentId: trimToNull(envelope.agentId), @@ -1190,9 +1243,6 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { sendConnectFrame(); return; } - if (typeof parsed.seq === "number") { - lastSeq = parsed.seq; - } if (parsed.event === "tick") { lastTickAt = Date.now(); } @@ -1334,6 +1384,21 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { } async function handleQueryRequest(envelope: OpenclawInboundEnvelope, res: ServerResponse): Promise { + const resolvedTarget = resolveTarget(envelope.targetHint ?? config.defaultTarget); + if (resolvedTarget.resolvedTarget !== "cto") { + const dispatch = await dispatchInbound("hook", envelope); + jsonResponse(res, 200, { + ok: true, + accepted: true, + async: true, + status: "working", + requestId: dispatch.requestId, + duplicate: dispatch.duplicate, + sessionId: dispatch.sessionId, + routeTarget: dispatch.routeTarget, + }); + return; + } const timeoutMs = Number.isFinite(Number(envelope.timeoutMs)) ? Math.max(1_000, Math.min(300_000, Math.floor(Number(envelope.timeoutMs)))) : 120_000; @@ -1420,7 +1485,7 @@ export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { threadId: trimToNull(parsed.threadId), message: String(parsed.message ?? "").trim(), targetHint: parsed.targetHint ? normalizeTargetHint(parsed.targetHint, config.defaultTarget) : undefined, - context: sanitizeContext(parsed.context), + context: isRecord(parsed.context) ? parsed.context : null, timeoutMs: Number.isFinite(Number(parsed.timeoutMs)) ? Number(parsed.timeoutMs) : undefined, }; try { diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts index 3c1c6ca8c..088daf772 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts @@ -197,6 +197,7 @@ async function createFixture(options: { } afterEach(() => { + vi.clearAllTimers(); vi.useRealTimers(); }); @@ -219,9 +220,12 @@ describe("workerHeartbeatService", () => { fixture.heartbeat.syncFromConfig(); await vi.advanceTimersByTimeAsync(1_200); + // Assert directly after advancing fake timers -- waitForCondition cannot + // work here because its internal setTimeout/Date.now rely on real timers. const runs = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 5 }); expect(runs.length).toBeGreaterThan(0); expect(runs[0]?.wakeupReason).toBe("timer"); + expect(runs[0]?.status).toBe("completed"); fixture.dispose(); }); diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts index 7dfcb21d5..ca0931056 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts @@ -12,7 +12,7 @@ import type { } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb, SqlValue } from "../state/kvDb"; -import { safeJsonParse, nowIso, looksSensitiveKey } from "../shared/utils"; +import { clipText, getErrorMessage, safeJsonParse, nowIso, sanitizeStructuredData, redactSecrets } from "../shared/utils"; import type { WorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; import type { WorkerAgentService } from "./workerAgentService"; import type { WorkerBudgetService } from "./workerBudgetService"; @@ -90,13 +90,6 @@ type TimerEntry = { intervalSec: number; }; -const SENSITIVE_VALUE_PATTERNS = [ - /\bbearer\s+[a-z0-9._~+/=-]{12,}/i, - /\bsk-[a-z0-9]{12,}/i, - /\bgh[pousr]_[a-z0-9]{16,}/i, - /\bxox[baprs]-[a-z0-9-]{10,}/i, -]; - function clampLimit(limit: number | undefined, fallback = 80): number { const candidate = Number(limit ?? fallback); if (!Number.isFinite(candidate)) return fallback; @@ -179,52 +172,7 @@ function withinActiveHours(agent: AgentIdentity, at = new Date()): boolean { } function sanitizeContext(input: unknown): Record { - const looksSensitiveValue = (value: string): boolean => - SENSITIVE_VALUE_PATTERNS.some((pattern) => pattern.test(value)); - - const walk = (value: unknown, keyPath = ""): unknown => { - if (Array.isArray(value)) return value.map((entry, index) => walk(entry, `${keyPath}[${index}]`)); - if (value && typeof value === "object") { - const next: Record = {}; - for (const [key, child] of Object.entries(value as Record)) { - if (looksSensitiveKey(key)) { - next[key] = "[REDACTED]"; - continue; - } - next[key] = walk(child, keyPath ? `${keyPath}.${key}` : key); - } - return next; - } - if (typeof value === "string" && looksSensitiveValue(value.trim())) { - return "[REDACTED]"; - } - if (typeof value === "string" && value.length > 4_000) { - return `${value.slice(0, 4_000)}…`; - } - return value; - }; - - const walked = walk(input); - return walked && typeof walked === "object" && !Array.isArray(walked) - ? walked as Record - : {}; -} - -function outputPreview(outputText: string): string { - const trimmed = outputText.trim(); - if (!trimmed.length) return ""; - return trimmed.length <= 600 ? trimmed : `${trimmed.slice(0, 600)}…`; -} - -function clipText(value: string, maxChars: number): string { - const trimmed = value.trim(); - if (trimmed.length <= maxChars) return trimmed; - return `${trimmed.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); + return sanitizeStructuredData(input) ?? {}; } export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { @@ -621,6 +569,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { }, }, }); + const sanitizedOutput = redactSecrets(runtimeResult.outputText); updateRunFields(run.id, { status: runStatus, finished_at: finishedAt, @@ -632,7 +581,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { ok: runtimeResult.ok, statusCode: runtimeResult.statusCode ?? null, heartbeatOk, - outputPreview: outputPreview(runtimeResult.outputText), + outputPreview: clipText(sanitizedOutput, 600), provider: runtimeResult.provider ?? null, sessionId: runtimeResult.sessionId ?? null, }), @@ -649,7 +598,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { runtimeResult.effectiveSurface !== "process" && runtimeResult.effectiveSurface !== "openclaw_webhook" ? `Resumed via ${runtimeResult.effectiveSurface}.` : "", - heartbeatOk ? "No action required." : outputPreview(runtimeResult.outputText) || "No output.", + heartbeatOk ? "No action required." : clipText(sanitizedOutput, 600) || "No output.", ] .filter((entry) => entry.length > 0) .join(" "), @@ -671,7 +620,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { summary: clipText( [ runtimeResult.ok ? "Worker run completed." : "Worker run failed.", - heartbeatOk ? "No action required." : outputPreview(runtimeResult.outputText) || "No output.", + heartbeatOk ? "No action required." : clipText(sanitizedOutput, 600) || "No output.", ].join(" "), 360 ), diff --git a/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts b/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts index 84b7c17c3..1ac4a9d5e 100644 --- a/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts +++ b/apps/desktop/src/main/services/externalMcp/externalConnectionAuthService.ts @@ -690,7 +690,7 @@ export function createExternalConnectionAuthService(args: { await exchangeOAuthCode(session, code); finalizeSession(session, { status: "completed" }); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - res.end("External MCP connected. You can close this window and return to ADE."); + res.end("External MCP connected. You can close this window and return to ADE."); } catch (error: unknown) { const message = getErrorMessage(error); finalizeSession(session, { status: "failed", error: message }); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index f6f877292..8cec19b8d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1066,8 +1066,9 @@ async function resolveFirstAvailableLaneId( ): Promise { const laneId = typeof requestedLaneId === "string" ? requestedLaneId.trim() : ""; if (laneId) return laneId; + await ctx.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await ctx.laneService.list({ includeArchived: false, includeStatus: false }); - return lanes[0]?.id ?? ""; + return (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id ?? ""; } async function resolveLaneOverlayContext(ctx: AppContext, laneId: string) { @@ -3126,6 +3127,10 @@ export function registerIpc({ const lane = await ctx.laneService.importBranch(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); + triggerAutoContextDocs(ctx, { + event: "lane_create", + reason: `lanes_import_branch:${lane.id}`, + }); return lane; }); @@ -3134,6 +3139,10 @@ export function registerIpc({ const lane = await ctx.laneService.attach(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); + triggerAutoContextDocs(ctx, { + event: "lane_create", + reason: `lanes_attach:${lane.id}`, + }); return lane; }); @@ -3142,6 +3151,10 @@ export function registerIpc({ const lane = await ctx.laneService.adoptAttached(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); + triggerAutoContextDocs(ctx, { + event: "lane_create", + reason: `lanes_adopt_attached:${lane.id}`, + }); return lane; }); @@ -3819,6 +3832,15 @@ export function registerIpc({ ipcMain.handle(IPC.agentChatSaveTempAttachment, async (_event, arg: { data: string; filename: string }): Promise<{ path: string }> => { const ctx = getCtx(); + const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; + const maxEncodedLength = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4; + if (typeof arg.data === "string" && arg.data.length > maxEncodedLength) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const content = Buffer.from(arg.data, "base64"); + if (content.byteLength > MAX_ATTACHMENT_BYTES) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } // Save within the project's .ade directory so CLI subprocesses (Claude Code) // have filesystem access. Fall back to system temp if no project is open. const baseDir = ctx.project?.rootPath @@ -3827,7 +3849,7 @@ export function registerIpc({ fs.mkdirSync(baseDir, { recursive: true }); const ext = path.extname(arg.filename) || ".png"; const destPath = path.join(baseDir, `${randomUUID()}${ext}`); - fs.writeFileSync(destPath, Buffer.from(arg.data, "base64")); + fs.writeFileSync(destPath, content); return { path: destPath }; }); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 2558b4899..be03d9c8b 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -64,6 +64,47 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGitOrThrow).mockReset(); }); + it("recreates the primary lane when the only stored primary lane is archived", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-archived-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-03-11T12:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-primary-archived", repoRoot, "demo", "main", now, now], + ); + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["lane-main-archived", "proj-primary-archived", "Main", null, "primary", "main", "main", repoRoot, null, 1, null, null, null, null, "archived", now, now], + ); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-primary-archived", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await service.ensurePrimaryLane(); + + const lanes = await service.list({ includeArchived: true, includeStatus: false }); + const activePrimary = lanes.find((lane) => lane.laneType === "primary" && lane.archivedAt == null); + expect(activePrimary).toBeTruthy(); + expect(lanes.filter((lane) => lane.laneType === "primary")).toHaveLength(2); + }); + it("moves unstaged and untracked changes into a new child lane", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-success-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 4286413b5..d84877163 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -579,9 +579,17 @@ export function createLaneService({ } }; + /** Look up the active (non-archived) primary lane. */ + const getActivePrimaryLane = (): { id: string; branch_ref: string } | undefined => { + return db.get<{ id: string; branch_ref: string }>( + "select id, branch_ref from lanes where project_id = ? and lane_type = 'primary' and status != 'archived' limit 1", + [projectId], + ) ?? undefined; + }; + const ensurePrimaryLane = async (): Promise => { const existing = db.get<{ id: string }>( - "select id from lanes where project_id = ? and lane_type = 'primary' limit 1", + "select id from lanes where project_id = ? and lane_type = 'primary' and status != 'archived' limit 1", [projectId] ); if (existing?.id) return; @@ -1205,7 +1213,12 @@ export function createLaneService({ }); const parentLaneIdRaw = typeof args.parentLaneId === "string" ? args.parentLaneId.trim() : ""; - const parentLaneId = parentLaneIdRaw.length ? parentLaneIdRaw : null; + let parentLaneId = parentLaneIdRaw.length ? parentLaneIdRaw : null; + // Default to primary lane when no parent is specified. + if (!parentLaneId) { + const primaryRow = getActivePrimaryLane(); + if (primaryRow?.id) parentLaneId = primaryRow.id; + } const parent = parentLaneId ? getLaneRow(parentLaneId) : null; if (parentLaneId && !parent) throw new Error(`Parent lane not found: ${parentLaneId}`); if (parent && parent.status === "archived") throw new Error("Parent lane is archived"); @@ -2113,7 +2126,11 @@ export function createLaneService({ const laneId = randomUUID(); const now = new Date().toISOString(); - const baseRef = defaultBaseRef; + + // Default parent to the primary lane so attached lanes are properly parented. + const primaryRow = getActivePrimaryLane(); + const parentLaneId = primaryRow?.id ?? null; + const baseRef = primaryRow?.branch_ref ?? defaultBaseRef; db.run( ` @@ -2121,9 +2138,9 @@ export function createLaneService({ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, null, null, null, null, 'active', ?, null) + values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, ?, null, null, null, 'active', ?, null) `, - [laneId, projectId, laneName, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, now] + [laneId, projectId, laneName, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, parentLaneId, now] ); invalidateLaneListCache(); @@ -2139,13 +2156,18 @@ export function createLaneService({ const row = getLaneRow(laneId); if (!row) throw new Error(`Failed to attach lane: ${laneId}`); + const rowsById = getRowsById(true); const status = await computeLaneStatus(attachedPath, baseRef, branchRef); + const parentRow = parentLaneId ? getLaneRow(parentLaneId) : null; + const parentStatus = parentRow + ? await computeLaneStatus(parentRow.worktree_path, parentRow.base_ref, parentRow.branch_ref) + : null; return toLaneSummary({ row, status, - parentStatus: null, + parentStatus, childCount: 0, - stackDepth: 0 + stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }) }); }, diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts index fc5ff0fa4..a3df47350 100644 --- a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts +++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts @@ -262,11 +262,11 @@ export function createOAuthRedirectService({ return ` OAuth Routing Error — ADE

OAuth Callback Routing Failed

diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index fec44b0a6..c2a587ca7 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -101,7 +101,7 @@ import { extractFirstJsonObject } from "../ai/utils"; import { buildIntegrationPreflight } from "./integrationPlanning"; import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; -import { asNumber, asString, normalizeBranchName, nowIso } from "../shared/utils"; +import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso } from "../shared/utils"; type PullRequestRow = { id: string; @@ -1877,70 +1877,95 @@ export function createPrService({ }); const mergeCommitSha = asString(merge.data?.sha) || null; + + // --- Post-merge cleanup: failures here must not mask a successful merge --- const headBranch = row.head_branch; let branchDeleted = false; - try { - await githubService.apiRequest({ - method: "DELETE", - path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` - }); - branchDeleted = true; - } catch (error) { - logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: error instanceof Error ? error.message : String(error) }); - } + let laneArchived = false; - // Remove PR from any group membership before archiving (lane archive blocks if still in a group) - db.run("delete from pr_group_members where pr_id = ?", [row.id]); + try { + try { + await githubService.apiRequest({ + method: "DELETE", + path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` + }); + branchDeleted = true; + } catch (error) { + logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: getErrorMessage(error) }); + } - let laneArchived = false; - if (args.archiveLane) { + // Remove PR from any group membership before archiving (lane archive blocks if still in a group) try { - await laneService.archive({ laneId: row.lane_id }); - laneArchived = true; - } catch (archiveErr) { - logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: archiveErr instanceof Error ? archiveErr.message : String(archiveErr) }); + db.run("delete from pr_group_members where pr_id = ?", [row.id]); + } catch (groupErr) { + logger.warn("prs.group_membership_cleanup_failed", { prId: row.id, error: getErrorMessage(groupErr) }); } - } - await fetchRemoteTrackingBranch({ - projectRoot, - targetBranch: row.base_branch, - }).catch((error) => { - logger.warn("prs.fetch_base_branch_failed", { - prId: row.id, - baseBranch: row.base_branch, - error: error instanceof Error ? error.message : String(error), - }); - }); - try { - laneService.invalidateCache?.(); - } catch (cacheError) { - logger.warn("prs.lane_cache_invalidation_failed", { - prId: row.id, - error: cacheError instanceof Error ? cacheError.message : String(cacheError), + if (args.archiveLane) { + try { + await laneService.archive({ laneId: row.lane_id }); + laneArchived = true; + } catch (archiveErr) { + logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: getErrorMessage(archiveErr) }); + } + } + + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: row.base_branch, + }).catch((error) => { + logger.warn("prs.fetch_base_branch_failed", { + prId: row.id, + baseBranch: row.base_branch, + error: getErrorMessage(error), + }); }); - } + try { + laneService.invalidateCache?.(); + } catch (cacheError) { + logger.warn("prs.lane_cache_invalidation_failed", { + prId: row.id, + error: getErrorMessage(cacheError), + }); + } - operationService.finish({ - operationId: op.operationId, - status: "succeeded", - metadataPatch: { mergeCommitSha, branchDeleted, laneArchived } - }); + operationService.finish({ + operationId: op.operationId, + status: "succeeded", + metadataPatch: { mergeCommitSha, branchDeleted, laneArchived } + }); - markHotRefresh([row.id]); - await refreshOne(row.id).catch(() => {}); - await conflictService?.scanRebaseNeeds().catch((error) => { - logger.warn("prs.refresh_rebase_needs_failed", { - prId: row.id, - error: error instanceof Error ? error.message : String(error), + markHotRefresh([row.id]); + await refreshOne(row.id).catch(() => {}); + await conflictService?.scanRebaseNeeds().catch((error) => { + logger.warn("prs.refresh_rebase_needs_failed", { + prId: row.id, + error: getErrorMessage(error), + }); }); - }); - await rebaseSuggestionService?.refresh().catch((error) => { - logger.warn("prs.refresh_rebase_suggestions_failed", { + await rebaseSuggestionService?.refresh().catch((error) => { + logger.warn("prs.refresh_rebase_suggestions_failed", { + prId: row.id, + error: getErrorMessage(error), + }); + }); + } catch (cleanupError) { + // The merge itself succeeded -- cleanup failure must not mask that. + const cleanupMsg = getErrorMessage(cleanupError); + logger.error("prs.post_merge_cleanup_failed", { prId: row.id, - error: error instanceof Error ? error.message : String(error), + mergeCommitSha, + error: cleanupMsg, }); - }); + // Best-effort: mark the operation as succeeded even if cleanup threw + try { + operationService.finish({ + operationId: op.operationId, + status: "succeeded", + metadataPatch: { mergeCommitSha, branchDeleted, laneArchived, cleanupError: cleanupMsg } + }); + } catch { /* already finished or double-finish -- ignore */ } + } return { prId: row.id, diff --git a/apps/desktop/src/main/services/prs/queueLandingService.ts b/apps/desktop/src/main/services/prs/queueLandingService.ts index 92a522b11..a169853a8 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.ts @@ -352,12 +352,65 @@ export function createQueueLandingService({ return value.includes("merge conflict") || value.includes("resolve conflicts"); }; + const ALLOWED_TRANSITIONS: Record = { + pending: ["landing", "rebasing", "skipped", "paused"], + landing: ["landing", "landed", "failed", "paused"], + rebasing: ["resolving", "pending", "failed", "paused"], + resolving: ["pending", "failed", "paused"], + landed: [], + failed: ["skipped"], + skipped: [], + paused: ["pending", "landing", "skipped"], + }; + + const isValidTransition = (from: QueueEntryState, to: QueueEntryState): boolean => { + return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false; + }; + + /** Log and reject an invalid transition. Returns true when the transition is allowed. */ + const guardTransition = ( + entry: QueueLandingEntry, + to: QueueEntryState, + context?: Record, + ): boolean => { + if (isValidTransition(entry.state, to)) return true; + logger.warn("queue_landing.invalid_transition", { + prId: entry.prId, + from: entry.state, + to, + ...context, + }); + return false; + }; + + /** Mark an entry as successfully landed and advance the queue position. */ + const markEntryLanded = ( + state: QueueLandingState, + entry: QueueLandingEntry, + index: number, + mergeCommitSha: string | null | undefined, + ): void => { + entry.state = "landed"; + entry.error = undefined; + entry.waitingOn = null; + entry.mergeCommitSha = mergeCommitSha; + entry.updatedAt = nowIso(); + state.currentPosition = index + 1; + state.activePrId = null; + state.activeResolverRunId = null; + state.lastError = null; + state.waitReason = null; + persistAndEmitState(state); + emitQueueStep(state.groupId, entry.prId, "landed", index); + }; + const pauseWithReason = ( state: QueueLandingState, entry: QueueLandingEntry, waitReason: QueueWaitReason, message: string, ): QueueLandingState => { + if (!guardTransition(entry, "paused", { reason: message })) return state; entry.state = "paused"; entry.waitingOn = waitReason; entry.error = message; @@ -377,6 +430,7 @@ export function createQueueLandingService({ waitReason: QueueWaitReason, message: string, ): QueueLandingState => { + if (!guardTransition(entry, "failed", { reason: message })) return state; entry.state = "failed"; entry.waitingOn = waitReason; entry.error = message; @@ -385,6 +439,7 @@ export function createQueueLandingService({ state.lastError = message; state.waitReason = waitReason; state.activePrId = entry.prId; + state.activeResolverRunId = null; persistAndEmitState(state); emitQueueStep(state.groupId, entry.prId, "failed", entry.position); return state; @@ -425,6 +480,10 @@ export function createQueueLandingService({ originRunId: state.config.originRunId, originLabel: state.config.originLabel ?? `queue:${state.groupId}`, }); + if (isQueueCancelledOrDone(state.queueId)) { + logger.debug("queue_landing.cancelled_during_resolve", { queueId: state.queueId, prId: entry.prId }); + return { ok: false, error: "Queue was cancelled or stopped during conflict resolution." }; + } state.activeResolverRunId = run.runId; entry.resolverRunId = run.runId; persistAndEmitState(state); @@ -445,6 +504,10 @@ export function createQueueLandingService({ }; } + if (isQueueCancelledOrDone(state.queueId)) { + logger.debug("queue_landing.cancelled_before_commit", { queueId: state.queueId, prId: entry.prId }); + return { ok: false, error: "Queue was cancelled or stopped before committing resolver changes." }; + } const commitMessage = `Resolve queue conflicts for PR #${entry.prNumber ?? entry.prId} via ADE`; await runGitOrThrow(["add", "--", ...touchedPaths], { cwd: lane.worktreePath, timeoutMs: 60_000 }); await runGitOrThrow(["commit", "-m", commitMessage, "--", ...touchedPaths], { @@ -456,6 +519,9 @@ export function createQueueLandingService({ await pushLaneBranch(entry.laneId); entry.resolvedByAi = true; + // Return the entry to the landing state before the retry attempt so the + // existing queue transition rules still apply on the second land call. + entry.state = "landing"; entry.mergeCommitSha = commitSha; entry.updatedAt = nowIso(); entry.error = undefined; @@ -466,6 +532,14 @@ export function createQueueLandingService({ return { ok: true, run }; }; + /** Re-read the persisted row and return true if the queue has been cancelled or completed externally. */ + const isQueueCancelledOrDone = (queueId: string): boolean => { + const freshRow = getRow(queueId); + if (!freshRow) return true; + const freshState = freshRow.state as QueueState; + return freshState === "cancelled" || freshState === "completed" || freshState === "paused"; + }; + const launchLandingLoop = (queueId: string): void => { const prior = activeLandingLoops.get(queueId) ?? Promise.resolve(); const loopPromise = prior.then(async () => { @@ -494,6 +568,7 @@ export function createQueueLandingService({ } const entry = state.entries[index]!; + if (!guardTransition(entry, "landing", { queueId })) return; state.currentPosition = index; state.activePrId = entry.prId; state.activeResolverRunId = null; @@ -508,6 +583,10 @@ export function createQueueLandingService({ try { if (state.config.ciGating) { const status = await prService.getStatus(entry.prId); + if (isQueueCancelledOrDone(queueId)) { + logger.debug("queue_landing.cancelled_after_ci_check", { queueId, prId: entry.prId }); + return; + } if (status.checksStatus === "pending" || status.checksStatus === "failing") { pauseWithReason( state, @@ -539,8 +618,16 @@ export function createQueueLandingService({ method: state.config.method, archiveLane: state.config.archiveLane, }); + if (isQueueCancelledOrDone(queueId)) { + logger.debug("queue_landing.cancelled_after_land", { queueId, prId: entry.prId }); + return; + } if (!landResult.success && isMergeConflictMessage(landResult.error)) { const resolved = await maybeResolveConflict(state, entry); + if (isQueueCancelledOrDone(queueId)) { + logger.debug("queue_landing.cancelled_after_resolve", { queueId, prId: entry.prId }); + return; + } if (!resolved.ok) { failEntry(state, entry, resolved.error.includes("manual") ? "manual" : "resolver_failed", resolved.error); logger.warn("queue_landing.resolve_failed", { @@ -550,11 +637,19 @@ export function createQueueLandingService({ }); return; } + // The shared resolver hands control back to the normal landing path. + // Mark the entry as landing again so the retry can complete valid queue-state transitions. + entry.state = "landing"; + entry.updatedAt = nowIso(); const retried = await prService.land({ prId: entry.prId, method: state.config.method, archiveLane: state.config.archiveLane, }); + if (isQueueCancelledOrDone(queueId)) { + logger.debug("queue_landing.cancelled_after_retry_land", { queueId, prId: entry.prId }); + return; + } if (!retried.success) { if (state.config.ciGating && isMergeConflictMessage(retried.error)) { failEntry(state, entry, "merge_conflict", retried.error ?? "Queue PR still has merge conflicts after AI resolution."); @@ -565,18 +660,8 @@ export function createQueueLandingService({ } return; } - entry.state = "landed"; - entry.error = undefined; - entry.waitingOn = null; - entry.mergeCommitSha = retried.mergeCommitSha; - entry.updatedAt = nowIso(); - state.currentPosition = index + 1; - state.activePrId = null; - state.activeResolverRunId = null; - state.lastError = null; - state.waitReason = null; - persistAndEmitState(state); - emitQueueStep(state.groupId, entry.prId, "landed", index); + if (!guardTransition(entry, "landed", { queueId })) return; + markEntryLanded(state, entry, index, retried.mergeCommitSha); continue; } @@ -592,18 +677,8 @@ export function createQueueLandingService({ return; } - entry.state = "landed"; - entry.error = undefined; - entry.waitingOn = null; - entry.mergeCommitSha = landResult.mergeCommitSha; - entry.updatedAt = nowIso(); - state.currentPosition = index + 1; - state.activePrId = null; - state.activeResolverRunId = null; - state.lastError = null; - state.waitReason = null; - persistAndEmitState(state); - emitQueueStep(state.groupId, entry.prId, "landed", index); + if (!guardTransition(entry, "landed", { queueId })) return; + markEntryLanded(state, entry, index, landResult.mergeCommitSha); } catch (error) { const message = getErrorMessage(error); failEntry(state, entry, "manual", message); @@ -708,7 +783,7 @@ export function createQueueLandingService({ if (state.state !== "paused") return state; state.config = resolveQueueConfig(args, state, getGroup(state.groupId)); const currentEntry = state.entries[state.currentPosition]; - if (currentEntry && (currentEntry.state === "failed" || currentEntry.state === "paused" || currentEntry.state === "resolving")) { + if (currentEntry && (currentEntry.state === "failed" || currentEntry.state === "paused" || currentEntry.state === "resolving" || currentEntry.state === "landing")) { currentEntry.state = "pending"; currentEntry.error = undefined; currentEntry.waitingOn = null; @@ -735,10 +810,17 @@ export function createQueueLandingService({ const state = rowToState(row); if (state.state === "completed" || state.state === "cancelled") return state; for (const entry of state.entries) { - if (entry.state === "pending" || entry.state === "landing" || entry.state === "failed" || entry.state === "paused" || entry.state === "resolving") { + if (isValidTransition(entry.state, "skipped")) { entry.state = "skipped"; entry.waitingOn = "canceled"; entry.updatedAt = nowIso(); + } else if (entry.state !== "landed" && entry.state !== "skipped") { + // Force-cancel entries in states that don't normally allow skip (e.g. landing, resolving) + logger.warn("queue_landing.force_cancel_entry", { queueId, prId: entry.prId, fromState: entry.state }); + entry.state = "failed"; + entry.error = "Queue cancelled while entry was in progress."; + entry.waitingOn = "canceled"; + entry.updatedAt = nowIso(); } } state.state = "cancelled"; @@ -758,6 +840,7 @@ export function createQueueLandingService({ const state = rowToState(row); const entry = state.entries.find((candidate) => candidate.prId === prId); if (!entry || entry.state === "landed") return state; + if (!guardTransition(entry, "skipped", { queueId })) return state; entry.state = "skipped"; entry.waitingOn = null; entry.error = undefined; diff --git a/apps/desktop/src/main/services/shared/utils.test.ts b/apps/desktop/src/main/services/shared/utils.test.ts new file mode 100644 index 000000000..266df0216 --- /dev/null +++ b/apps/desktop/src/main/services/shared/utils.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, it } from "vitest"; +import { + isRecord, + asString, + asNumber, + nowIso, + getErrorMessage, + isEnoentError, + toMemoryEntryDto, + uniqueSorted, + uniqueStrings, + asArray, + firstLine, + parseDiffNameOnly, + safeJsonParse, + isWithinDir, + toOptionalString, + normalizeRelative, + normalizeBranchName, + hasNullByte, + parseIsoToEpoch, + sha256Hex, + stableStringify, + toBase64Url, + createPkcePair, + escapeRegExp, + globToRegExp, + matchesGlob, + normalizeSet, + isEnvRef, + hasEnvRefToken, + looksSensitiveKey, + looksSensitiveValue, + sanitizeStructuredData, +} from "./utils"; + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it("returns false for non-objects", () => { + expect(isRecord(null)).toBe(false); + expect(isRecord(undefined)).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord("str")).toBe(false); + expect(isRecord([])).toBe(false); + }); +}); + +describe("asString", () => { + it("returns the value when it is a string", () => { + expect(asString("hello")).toBe("hello"); + expect(asString("")).toBe(""); + }); + + it("returns fallback for non-strings", () => { + expect(asString(42)).toBe(""); + expect(asString(null, "default")).toBe("default"); + expect(asString(undefined, "x")).toBe("x"); + }); +}); + +describe("asNumber", () => { + it("returns the value when it is a finite number", () => { + expect(asNumber(42)).toBe(42); + expect(asNumber(0)).toBe(0); + expect(asNumber(-1.5)).toBe(-1.5); + }); + + it("parses numeric strings", () => { + expect(asNumber("10")).toBe(10); + expect(asNumber("3.14")).toBe(3.14); + }); + + it("returns fallback for NaN, Infinity, and non-numeric values", () => { + expect(asNumber(NaN, 99)).toBe(99); + expect(asNumber(Infinity, 99)).toBe(99); + expect(asNumber("not-a-number", 0)).toBe(0); + // Number(null) is 0 which is finite, so it returns 0 not the fallback + expect(asNumber(null, 5)).toBe(0); + }); +}); + +describe("nowIso", () => { + it("returns a valid ISO 8601 string", () => { + const result = nowIso(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(Number.isNaN(Date.parse(result))).toBe(false); + }); +}); + +describe("getErrorMessage", () => { + it("extracts message from Error instances", () => { + expect(getErrorMessage(new Error("something went wrong"))).toBe("something went wrong"); + }); + + it("converts non-Error values to string", () => { + expect(getErrorMessage("plain string")).toBe("plain string"); + expect(getErrorMessage(42)).toBe("42"); + expect(getErrorMessage(null)).toBe("null"); + }); +}); + +describe("isEnoentError", () => { + it("returns true for ENOENT error objects", () => { + const err = Object.assign(new Error("not found"), { code: "ENOENT" }); + expect(isEnoentError(err)).toBe(true); + }); + + it("returns false for other error codes", () => { + const err = Object.assign(new Error("perm"), { code: "EACCES" }); + expect(isEnoentError(err)).toBe(false); + }); + + it("returns false for null/undefined", () => { + expect(isEnoentError(null)).toBe(false); + expect(isEnoentError(undefined)).toBe(false); + }); +}); + +describe("toMemoryEntryDto", () => { + it("normalizes embedded field to boolean", () => { + expect(toMemoryEntryDto({ id: "1", embedded: true })).toEqual({ id: "1", embedded: true }); + expect(toMemoryEntryDto({ id: "2", embedded: undefined as any })).toEqual({ id: "2", embedded: false }); + expect(toMemoryEntryDto({ id: "3" } as any)).toEqual({ id: "3", embedded: false }); + }); +}); + +describe("uniqueSorted", () => { + it("deduplicates and sorts strings", () => { + expect(uniqueSorted(["b", "a", "b", "c", "a"])).toEqual(["a", "b", "c"]); + }); + + it("returns empty for empty input", () => { + expect(uniqueSorted([])).toEqual([]); + }); +}); + +describe("uniqueStrings", () => { + it("deduplicates without sorting", () => { + const result = uniqueStrings(["b", "a", "b"]); + expect(result).toHaveLength(2); + expect(result).toContain("a"); + expect(result).toContain("b"); + }); +}); + +describe("asArray", () => { + it("returns arrays as-is", () => { + expect(asArray([1, 2])).toEqual([1, 2]); + }); + + it("returns empty array for non-arrays", () => { + expect(asArray("str")).toEqual([]); + expect(asArray(null)).toEqual([]); + expect(asArray(42)).toEqual([]); + }); +}); + +describe("firstLine", () => { + it("extracts the first line from a multi-line string", () => { + expect(firstLine("hello\nworld")).toBe("hello"); + }); + + it("trims whitespace from the first line", () => { + expect(firstLine(" hello \nworld")).toBe("hello"); + }); + + it("returns empty string for empty input", () => { + expect(firstLine("")).toBe(""); + }); +}); + +describe("parseDiffNameOnly", () => { + it("parses newline-separated file names", () => { + expect(parseDiffNameOnly("a.ts\nb.ts\nc.ts\n")).toEqual(["a.ts", "b.ts", "c.ts"]); + }); + + it("filters out empty lines and trims whitespace", () => { + expect(parseDiffNameOnly(" a.ts \n\n b.ts \n\n")).toEqual(["a.ts", "b.ts"]); + }); + + it("returns empty for empty input", () => { + expect(parseDiffNameOnly("")).toEqual([]); + }); +}); + +describe("safeJsonParse", () => { + it("parses valid JSON", () => { + expect(safeJsonParse('{"a":1}', {})).toEqual({ a: 1 }); + }); + + it("returns fallback for invalid JSON", () => { + expect(safeJsonParse("not json", { default: true })).toEqual({ default: true }); + }); + + it("returns fallback for null/undefined/empty", () => { + expect(safeJsonParse(null, "fallback")).toBe("fallback"); + expect(safeJsonParse(undefined, 42)).toBe(42); + expect(safeJsonParse("", [])).toEqual([]); + }); +}); + +describe("isWithinDir", () => { + it("returns true when candidate is inside root", () => { + expect(isWithinDir("/project", "/project/src/file.ts")).toBe(true); + }); + + it("returns true when candidate equals root", () => { + expect(isWithinDir("/project", "/project")).toBe(true); + }); + + it("returns false when candidate is outside root", () => { + expect(isWithinDir("/project", "/other/file.ts")).toBe(false); + expect(isWithinDir("/project/src", "/project/file.ts")).toBe(false); + }); +}); + +describe("toOptionalString", () => { + it("returns trimmed string for non-empty values", () => { + expect(toOptionalString(" hello ")).toBe("hello"); + }); + + it("returns null for empty or non-string values", () => { + expect(toOptionalString("")).toBeNull(); + expect(toOptionalString(" ")).toBeNull(); + expect(toOptionalString(42)).toBeNull(); + expect(toOptionalString(null)).toBeNull(); + }); +}); + +describe("normalizeRelative", () => { + it("strips leading ./ and backslashes", () => { + expect(normalizeRelative("./src/file.ts")).toBe("src/file.ts"); + expect(normalizeRelative("src\\dir\\file.ts")).toBe("src/dir/file.ts"); + expect(normalizeRelative("/absolute/path")).toBe("absolute/path"); + }); +}); + +describe("normalizeBranchName", () => { + it("strips refs/heads/ prefix", () => { + expect(normalizeBranchName("refs/heads/main")).toBe("main"); + }); + + it("strips origin/ prefix", () => { + expect(normalizeBranchName("origin/feature-x")).toBe("feature-x"); + }); + + it("returns as-is for plain branch names", () => { + expect(normalizeBranchName("main")).toBe("main"); + }); +}); + +describe("hasNullByte", () => { + it("returns true when buffer contains null byte", () => { + expect(hasNullByte(Buffer.from([65, 0, 66]))).toBe(true); + }); + + it("returns false for buffers without null bytes", () => { + expect(hasNullByte(Buffer.from("hello"))).toBe(false); + }); + + it("returns false for empty buffer", () => { + expect(hasNullByte(Buffer.from([]))).toBe(false); + }); + + it("only scans the first 8192 bytes", () => { + const largeBuffer = Buffer.alloc(16384, 65); // all 'A' + largeBuffer[10000] = 0; // null byte after the 8192 limit + expect(hasNullByte(largeBuffer)).toBe(false); + + largeBuffer[100] = 0; // null byte within the limit + expect(hasNullByte(largeBuffer)).toBe(true); + }); +}); + +describe("parseIsoToEpoch", () => { + it("parses valid ISO strings to epoch ms", () => { + const result = parseIsoToEpoch("2026-03-01T00:00:00.000Z"); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(Date.parse("2026-03-01T00:00:00.000Z")); + }); + + it("returns NaN for null/undefined/empty", () => { + expect(Number.isNaN(parseIsoToEpoch(null))).toBe(true); + expect(Number.isNaN(parseIsoToEpoch(undefined))).toBe(true); + expect(Number.isNaN(parseIsoToEpoch(""))).toBe(true); + }); + + it("returns NaN for invalid date strings", () => { + expect(Number.isNaN(parseIsoToEpoch("not-a-date"))).toBe(true); + }); +}); + +describe("sha256Hex", () => { + it("produces consistent hex digest for strings", () => { + const hash1 = sha256Hex("hello"); + const hash2 = sha256Hex("hello"); + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + it("produces different digests for different inputs", () => { + expect(sha256Hex("a")).not.toBe(sha256Hex("b")); + }); +}); + +describe("stableStringify", () => { + it("sorts object keys deterministically", () => { + const a = stableStringify({ b: 1, a: 2 }); + const b = stableStringify({ a: 2, b: 1 }); + expect(a).toBe(b); + }); + + it("handles nested objects and arrays", () => { + const result = stableStringify({ z: { b: 1, a: 2 }, arr: [3, 2, 1] }); + const parsed = JSON.parse(result); + expect(Object.keys(parsed)).toEqual(["arr", "z"]); + expect(Object.keys(parsed.z)).toEqual(["a", "b"]); + expect(parsed.arr).toEqual([3, 2, 1]); // arrays preserve order + }); +}); + +describe("toBase64Url", () => { + it("produces URL-safe base64 without padding", () => { + const result = toBase64Url(Buffer.from("hello world")); + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + }); +}); + +describe("createPkcePair", () => { + it("returns a verifier and challenge pair", () => { + const pair = createPkcePair(); + expect(pair.verifier).toBeTruthy(); + expect(pair.challenge).toBeTruthy(); + expect(pair.verifier).not.toBe(pair.challenge); + }); + + it("generates unique pairs on each call", () => { + const pair1 = createPkcePair(); + const pair2 = createPkcePair(); + expect(pair1.verifier).not.toBe(pair2.verifier); + }); +}); + +describe("escapeRegExp", () => { + it("escapes special regex characters (except * which is used for glob)", () => { + // Note: this escapeRegExp does NOT escape * because it's used with globToRegExp + expect(escapeRegExp("a.b+c*d")).toBe("a\\.b\\+c*d"); + expect(escapeRegExp("foo[bar]")).toBe("foo\\[bar\\]"); + expect(escapeRegExp("$100")).toBe("\\$100"); + expect(escapeRegExp("(test)")).toBe("\\(test\\)"); + expect(escapeRegExp("a|b")).toBe("a\\|b"); + }); +}); + +describe("globToRegExp", () => { + it("converts glob patterns to case-insensitive regex", () => { + const re = globToRegExp("*.ts"); + expect(re.test("file.ts")).toBe(true); + expect(re.test("FILE.TS")).toBe(true); + expect(re.test("file.js")).toBe(false); + }); + + it("handles multiple wildcards", () => { + const re = globToRegExp("src*test*"); + expect(re.test("src/foo/test/bar")).toBe(true); + expect(re.test("src-test-x")).toBe(true); + }); + + it("returns ^$ for empty pattern", () => { + const re = globToRegExp(""); + expect(re.test("")).toBe(true); + expect(re.test("anything")).toBe(false); + }); +}); + +describe("matchesGlob", () => { + it("matches when pattern matches value", () => { + expect(matchesGlob("*.ts", "file.ts")).toBe(true); + }); + + it("returns true when pattern is empty", () => { + expect(matchesGlob("", "anything")).toBe(true); + expect(matchesGlob(null, "anything")).toBe(true); + }); + + it("returns false when value is empty but pattern is not", () => { + expect(matchesGlob("*.ts", "")).toBe(false); + expect(matchesGlob("*.ts", null as any)).toBe(false); + }); +}); + +describe("normalizeSet", () => { + it("lowercases, trims, and deduplicates", () => { + const result = normalizeSet([" A ", "b", "a", "C"]); + expect(result).toEqual(new Set(["a", "b", "c"])); + }); + + it("filters empty strings", () => { + const result = normalizeSet([" ", "", "x"]); + expect(result).toEqual(new Set(["x"])); + }); + + it("returns empty set for undefined", () => { + expect(normalizeSet(undefined)).toEqual(new Set()); + }); +}); + +describe("isEnvRef", () => { + it("returns true for valid env refs", () => { + expect(isEnvRef("${env:MY_VAR}")).toBe(true); + expect(isEnvRef("${env:API_KEY_123}")).toBe(true); + }); + + it("returns false for partial or invalid refs", () => { + expect(isEnvRef("${env:lowercase}")).toBe(false); + expect(isEnvRef("plain string")).toBe(false); + expect(isEnvRef("${env:VAR} extra")).toBe(false); + }); +}); + +describe("hasEnvRefToken", () => { + it("detects env refs anywhere in the string", () => { + expect(hasEnvRefToken("prefix ${env:MY_VAR} suffix")).toBe(true); + expect(hasEnvRefToken("${env:KEY}")).toBe(true); + }); + + it("returns false when no env ref is present", () => { + expect(hasEnvRefToken("no refs here")).toBe(false); + }); +}); + +describe("looksSensitiveKey", () => { + it("detects sensitive key patterns", () => { + expect(looksSensitiveKey("api_key")).toBe(true); + expect(looksSensitiveKey("apiKey")).toBe(true); + expect(looksSensitiveKey("api-key")).toBe(true); + expect(looksSensitiveKey("Authorization")).toBe(true); + expect(looksSensitiveKey("TOKEN")).toBe(true); + expect(looksSensitiveKey("secret")).toBe(true); + expect(looksSensitiveKey("password")).toBe(true); + }); + + it("returns false for non-sensitive keys", () => { + expect(looksSensitiveKey("name")).toBe(false); + expect(looksSensitiveKey("url")).toBe(false); + expect(looksSensitiveKey("count")).toBe(false); + }); +}); + +describe("looksSensitiveValue", () => { + it("detects bearer tokens", () => { + expect(looksSensitiveValue("Bearer abc123")).toBe(true); + }); + + it("detects sk- prefixed keys", () => { + expect(looksSensitiveValue("sk-abc123def456ghi7")).toBe(true); + }); + + it("detects GitHub tokens", () => { + expect(looksSensitiveValue("ghp_abcdefghijklmnopqrstuvwx")).toBe(true); + }); + + it("detects Slack tokens", () => { + expect(looksSensitiveValue("xoxb-1234567890-abcdef")).toBe(true); + }); + + it("returns false for non-sensitive values", () => { + expect(looksSensitiveValue("hello world")).toBe(false); + expect(looksSensitiveValue("12345")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(looksSensitiveValue("")).toBe(false); + expect(looksSensitiveValue(" ")).toBe(false); + }); +}); + +describe("sanitizeStructuredData", () => { + it("returns null for non-record input", () => { + expect(sanitizeStructuredData("string")).toBeNull(); + expect(sanitizeStructuredData(null)).toBeNull(); + expect(sanitizeStructuredData(42)).toBeNull(); + }); + + it("redacts sensitive keys", () => { + const result = sanitizeStructuredData({ token: "abc123", name: "test" }); + expect(result!.token).toBe("[REDACTED]"); + expect(result!.name).toBe("test"); + }); + + it("redacts sensitive values", () => { + const result = sanitizeStructuredData({ auth: "Bearer secret123" }); + expect(result!.auth).toBe("[REDACTED]"); + }); + + it("removes blocked top-level keys", () => { + const result = sanitizeStructuredData( + { name: "ok", blocked: "value" }, + { blockedTopLevelKeys: ["blocked"] }, + ); + expect(result).toEqual({ name: "ok" }); + }); + + it("truncates long strings", () => { + const long = "x".repeat(200); + const result = sanitizeStructuredData({ text: long }, { maxStringLength: 50 }); + expect(result!.text).toHaveLength(50); // 49 chars + ellipsis = maxStringLength + }); + + it("limits array entries", () => { + const result = sanitizeStructuredData( + { items: [1, 2, 3, 4, 5] }, + { maxArrayEntries: 2 }, + ); + expect(result!.items).toEqual([1, 2]); + }); + + it("limits object entries", () => { + const result = sanitizeStructuredData( + { a: 1, b: 2, c: 3, d: 4 }, + { maxObjectEntries: 2 }, + ); + expect(Object.keys(result!)).toHaveLength(2); + }); + + it("respects custom redaction text", () => { + const result = sanitizeStructuredData( + { token: "abc" }, + { redactionText: "***" }, + ); + expect(result!.token).toBe("***"); + }); + + it("handles nested objects and arrays", () => { + const result = sanitizeStructuredData({ + config: { + api_key: "secret", + name: "ok", + items: [{ token: "hidden" }, "normal text"], + }, + }); + const config = result!.config as Record; + expect(config.api_key).toBe("[REDACTED]"); + expect(config.name).toBe("ok"); + const items = config.items as unknown[]; + expect((items[0] as Record).token).toBe("[REDACTED]"); + expect(items[1]).toBe("normal text"); + }); +}); diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 468a5ce31..57bd3820f 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -320,6 +320,47 @@ export function normalizeSet(values: string[] | undefined): Set { return new Set((values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)); } +// ── Template rendering helpers ────────────────────────────────────── + +/** Walk a dotted path like "a.b.c" into a nested object. */ +export function getPathValue(source: Record, dottedPath: string): unknown { + const segments = dottedPath + .split(".") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (segments.length === 0) return null; + let cursor: unknown = source; + for (const segment of segments) { + if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return null; + cursor = (cursor as Record)[segment]; + } + return cursor; +} + +/** Render {{ path }} placeholders against a values object. */ +export function renderTemplateString(template: string, values: Record): string { + return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, rawPath) => { + const value = getPathValue(values, String(rawPath)); + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)).join(", "); + } + return JSON.stringify(value); + }); +} + +// ── Text clipping helpers ────────────────────────────────────────── + +/** Clip text to `maxLength`, appending an ellipsis if truncated. */ +export function clipText(value: string, maxLength: number): string { + const trimmed = value.trim(); + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + // ── Secret detection helpers ──────────────────────────────────────── const ENV_REF_PATTERN = /^\$\{env:[A-Z0-9_]+\}$/; @@ -333,6 +374,33 @@ export function hasEnvRefToken(value: string): boolean { return ENV_REF_TOKEN_PATTERN.test(value); } +/** + * Redact common secret patterns from a plain-text string. + * + * This complements `sanitizeStructuredData` (which operates on parsed objects) + * by scrubbing raw output text before it is persisted in logs or result_json. + */ +export function redactSecrets(text: string, replacement: string = "[REDACTED]"): string { + if (!text) return text; + return text + // Bearer tokens + .replace(/\bbearer\s+[A-Za-z0-9\-._~+/]+=*/gi, replacement) + // OpenAI / Anthropic-style keys + .replace(/\bsk-[A-Za-z0-9]{12,}\b/g, replacement) + // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) + .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replacement) + // Slack tokens + .replace(/\bxox[baprs]-[A-Za-z0-9\-]{10,}\b/g, replacement) + // AWS access keys + .replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement) + // GitHub fine-grained PATs (github_pat_...) + .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement) + // JSON-style sensitive keys: "apiKey":"...", "token":"...", "secret":"...", etc. + .replace(/"(api[_-]?key|secret|token|password|authorization)"\s*:\s*"[^"]{4,}"/gi, `"$1":"${replacement}"`) + // Generic high-entropy hex/base64 secrets assigned to common key names + .replace(/(api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["']?[A-Za-z0-9\-._~+/]{16,}["']?/gi, `$1=${replacement}`); +} + export function looksSensitiveKey(key: string): boolean { return /(token|secret|password|api[_-]?key|authorization)/i.test(key); } @@ -347,3 +415,74 @@ export function looksSensitiveValue(value: string): boolean { if (/api[_-]?key|secret|token|password/i.test(trimmed)) return true; return false; } + +export type SanitizeStructuredDataOptions = { + blockedTopLevelKeys?: Iterable; + maxArrayEntries?: number; + maxObjectEntries?: number; + maxStringLength?: number; + redactionText?: string; +}; + +export function sanitizeStructuredData( + input: unknown, + options: SanitizeStructuredDataOptions = {}, +): Record | null { + if (!isRecord(input)) return null; + + const blockedTopLevelKeys = new Set( + Array.from(options.blockedTopLevelKeys ?? []) + .map((value) => String(value ?? "").trim().toLowerCase()) + .filter(Boolean), + ); + const maxArrayEntries = Math.max(1, Math.floor(options.maxArrayEntries ?? 50)); + const maxObjectEntries = Math.max(1, Math.floor(options.maxObjectEntries ?? 50)); + const maxStringLength = Math.max(32, Math.floor(options.maxStringLength ?? 4_000)); + const redactionText = options.redactionText ?? "[REDACTED]"; + + const clipString = (value: string): string => { + if (value.length <= maxStringLength) return value; + return `${value.slice(0, maxStringLength - 1)}…`; + }; + + const seen = new WeakSet(); + + const walk = (value: unknown): unknown => { + if (Array.isArray(value)) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + return value.slice(0, maxArrayEntries).map((entry) => walk(entry)); + } + if (isRecord(value)) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + const next: Record = {}; + for (const [index, [key, child]] of Object.entries(value).entries()) { + if (index >= maxObjectEntries) break; + if (looksSensitiveKey(key)) { + next[key] = redactionText; + continue; + } + next[key] = walk(child); + } + return next; + } + if (typeof value === "string") { + if (looksSensitiveValue(value)) return redactionText; + return clipString(value); + } + return value; + }; + + const sanitized: Record = {}; + for (const [index, [key, value]] of Object.entries(input).entries()) { + if (index >= maxObjectEntries) break; + if (blockedTopLevelKeys.has(key.toLowerCase())) continue; + if (looksSensitiveKey(key)) { + sanitized[key] = redactionText; + continue; + } + sanitized[key] = walk(value); + } + return sanitized; +} diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 7b35a4ada..63710e57a 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2633,6 +2633,8 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { closeout_state text not null default 'pending', terminal_outcome text, last_error text, + route_context_json text, + execution_context_json text, source_issue_snapshot_json text not null default '{}', created_at text not null, updated_at text not null @@ -2645,6 +2647,8 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { try { db.run("alter table linear_workflow_runs add column pr_checks_status text"); } catch {} try { db.run("alter table linear_workflow_runs add column pr_review_status text"); } catch {} try { db.run("alter table linear_workflow_runs add column latest_review_note text"); } catch {} + try { db.run("alter table linear_workflow_runs add column route_context_json text"); } catch {} + try { db.run("alter table linear_workflow_runs add column execution_context_json text"); } catch {} db.run("create index if not exists idx_linear_workflow_runs_project_status on linear_workflow_runs(project_id, status, updated_at)"); db.run("create index if not exists idx_linear_workflow_runs_issue on linear_workflow_runs(project_id, issue_id, updated_at)"); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 60567fcd0..b027ee21d 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -6,6 +6,7 @@ import { Bonjour, type Service as BonjourService } from "bonjour-service"; import { WebSocketServer, WebSocket, type RawData } from "ws"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { + AgentChatEventEnvelope, CrsqlChangeRow, FileContent, FileTreeNode, @@ -14,11 +15,14 @@ import type { FilesWorkspace, PtyDataEvent, PtyExitEvent, + SyncChatEventPayload, SyncBrainStatusPayload, SyncChangesetBatchPayload, SyncCommandPayload, SyncCommandResultPayload, SyncEnvelope, + SyncChatSubscribeSnapshotPayload, + SyncChatUnsubscribePayload, SyncFileBlob, SyncFileRequest, SyncFileResponsePayload, @@ -29,6 +33,7 @@ import type { SyncPairingSession, SyncTerminalSnapshotPayload, } from "../../../shared/types"; +import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import type { Logger } from "../logging/logger"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createProjectConfigService } from "../config/projectConfigService"; @@ -73,6 +78,8 @@ type PeerState = { remoteAddress: string | null; remotePort: number | null; subscribedSessionIds: Set; + subscribedChatSessionIds: Set; + chatTranscriptOffsets: Map; }; type SyncHostServiceArgs = { @@ -311,6 +318,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { void pumpChanges().catch((error) => { args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); }); + void pumpChatEvents().catch((error) => { + args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); + }); }, pollIntervalMs); const heartbeatTimer = setInterval(() => { const sentAt = nowIso(); @@ -348,6 +358,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { remoteAddress: sanitizeRemoteAddress(request.socket.remoteAddress), remotePort: request.socket.remotePort ?? null, subscribedSessionIds: new Set(), + subscribedChatSessionIds: new Set(), + chatTranscriptOffsets: new Map(), }; peers.add(peer); ws.on("message", (raw) => { @@ -466,6 +478,62 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } + async function readChatTranscriptEventsSince( + transcriptPath: string, + startOffset: number, + ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(transcriptPath, "r"); + const stat = await fh.stat(); + const size = stat.size; + const normalizedStart = Math.max(0, Math.min(startOffset, size)); + if (size <= normalizedStart) { + return { events: [], nextOffset: size }; + } + + const out = Buffer.alloc(size - normalizedStart); + await fh.read(out, 0, out.length, normalizedStart); + const lastNewline = out.lastIndexOf(0x0a); + if (lastNewline < 0) { + return { events: [], nextOffset: normalizedStart }; + } + + const completeSlice = out.subarray(0, lastNewline + 1); + const raw = completeSlice.toString("utf8"); + return { + events: parseAgentChatTranscript(raw), + nextOffset: normalizedStart + completeSlice.length, + }; + } catch { + return { events: [], nextOffset: Math.max(0, startOffset) }; + } finally { + await fh?.close().catch(() => {}); + } + } + + async function pumpChatEvents(): Promise { + if (disposed) return; + + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + for (const sessionId of peer.subscribedChatSessionIds) { + const session = args.sessionService.get(sessionId); + if (!session?.transcriptPath) continue; + + const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; + const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); + if (nextOffset !== startOffset) { + peer.chatTranscriptOffsets.set(sessionId, nextOffset); + } + for (const event of events) { + const chatEventPayload: SyncChatEventPayload = event; + send(peer.ws, "chat_event", chatEventPayload); + } + } + } + } + async function pumpChanges(): Promise { if (disposed) return; const currentDbVersion = args.db.sync.getDbVersion(); @@ -840,6 +908,47 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } break; } + case "chat_subscribe": { + const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + peer.subscribedChatSessionIds.add(sessionId); + + const session = args.sessionService.get(sessionId); + const maxBytes = Math.max( + 1_024, + Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), + ); + const raw = session?.transcriptPath + ? await args.sessionService.readTranscriptTail( + session.transcriptPath, + maxBytes, + { raw: true, alignToLineBoundary: true }, + ) + : ""; + const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); + const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) + ? fs.statSync(session.transcriptPath).size + : 0; + peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + const snapshot: SyncChatSubscribeSnapshotPayload = { + sessionId, + capturedAt: nowIso(), + truncated: transcriptSize > maxBytes, + events, + }; + send(peer.ws, "chat_subscribe", snapshot, envelope.requestId); + break; + } + case "chat_unsubscribe": { + const payload = envelope.payload as SyncChatUnsubscribePayload | null; + const sessionId = toOptionalString(payload?.sessionId); + if (sessionId) { + peer.subscribedChatSessionIds.delete(sessionId); + peer.chatTranscriptOffsets.delete(sessionId); + } + break; + } case "command": await handleCommand(peer, envelope.requestId, envelope.payload as SyncCommandPayload); break; diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 2830bc821..99d816eca 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,9 +1,16 @@ import type { AgentChatCreateArgs, + AgentChatApproveArgs, + AgentChatDisposeArgs, AgentChatGetSummaryArgs, AgentChatListArgs, AgentChatProvider, + AgentChatRespondToInputArgs, + AgentChatResumeArgs, AgentChatSendArgs, + AgentChatSteerArgs, + AgentChatInterruptArgs, + AgentChatUpdateSessionArgs, ApplyLaneTemplateArgs, ArchiveLaneArgs, AttachLaneArgs, @@ -307,6 +314,84 @@ function parseAgentChatSendArgs(value: Record): AgentChatSendAr }; } +function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), + text: requireString(value.text, "chat.steer requires text."), + }; +} + +function parseAgentChatInterruptArgs(value: Record): AgentChatInterruptArgs { + return { + sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), + }; +} + +function parseAgentChatResumeArgs(value: Record): AgentChatResumeArgs { + return { + sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), + }; +} + +function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { + return { + sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), + itemId: requireString(value.itemId, "chat.approve requires itemId."), + decision: requireString(value.decision, "chat.approve requires decision.") as AgentChatApproveArgs["decision"], + ...(asTrimmedString(value.responseText) ? { responseText: asTrimmedString(value.responseText)! } : {}), + }; +} + +function parseAgentChatRespondToInputArgs(value: Record): AgentChatRespondToInputArgs { + const parsed: AgentChatRespondToInputArgs = { + sessionId: requireString(value.sessionId, "chat.respondToInput requires sessionId."), + itemId: requireString(value.itemId, "chat.respondToInput requires itemId."), + }; + + if (typeof value.decision === "string" && value.decision.trim().length > 0) { + parsed.decision = value.decision.trim() as AgentChatRespondToInputArgs["decision"]; + } + if (isRecord(value.answers)) { + parsed.answers = Object.fromEntries( + Object.entries(value.answers).map(([key, entry]) => { + if (Array.isArray(entry)) { + return [key, entry.map((item) => String(item))]; + } + return [key, String(entry)]; + }), + ); + } + if (typeof value.responseText === "string" && value.responseText.trim().length > 0) { + parsed.responseText = value.responseText.trim(); + } + return parsed; +} + +function parseAgentChatUpdateSessionArgs(value: Record): AgentChatUpdateSessionArgs { + const parsed: AgentChatUpdateSessionArgs = { + sessionId: requireString(value.sessionId, "chat.updateSession requires sessionId."), + }; + + if ("title" in value) parsed.title = value.title == null ? null : asTrimmedString(value.title) ?? null; + if ("modelId" in value) parsed.modelId = value.modelId == null ? undefined : asTrimmedString(value.modelId) as AgentChatUpdateSessionArgs["modelId"]; + if ("reasoningEffort" in value) parsed.reasoningEffort = value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null; + if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatUpdateSessionArgs["permissionMode"]; + if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatUpdateSessionArgs["interactionMode"]; + if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatUpdateSessionArgs["claudePermissionMode"]; + if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; + if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; + if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; + if ("unifiedPermissionMode" in value) parsed.unifiedPermissionMode = value.unifiedPermissionMode == null ? undefined : asTrimmedString(value.unifiedPermissionMode) as AgentChatUpdateSessionArgs["unifiedPermissionMode"]; + if ("computerUse" in value) parsed.computerUse = value.computerUse == null ? null : value.computerUse as AgentChatUpdateSessionArgs["computerUse"]; + return parsed; +} + +function parseAgentChatDisposeArgs(value: Record): AgentChatDisposeArgs { + return { + sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), + }; +} + function parseGetTranscriptArgs(value: Record): { sessionId: string; limit?: number; @@ -925,6 +1010,30 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg await requireService(args.agentChatService, "Agent chat service not available.").sendMessage(parseAgentChatSendArgs(payload)); return { ok: true }; }); + register("chat.interrupt", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); + return { ok: true }; + }); + register("chat.steer", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); + return { ok: true }; + }); + register("chat.approve", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").approveToolUse(parseAgentChatApproveArgs(payload)); + return { ok: true }; + }); + register("chat.respondToInput", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); + return { ok: true }; + }); + register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); + register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); + return { ok: true }; + }); register("chat.models", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 445035e93..df9d54152 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -947,7 +947,7 @@ declare global { getForLane: (laneId: string) => Promise; listAll: () => Promise; refresh: (args?: { prId?: string; prIds?: string[] }) => Promise; - getStatus: (prId: string) => Promise; + getStatus: (prId: string) => Promise; getChecks: (prId: string) => Promise; getComments: (prId: string) => Promise; getReviews: (prId: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 88eb21e5b..057ca531c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1332,7 +1332,7 @@ contextBridge.exposeInMainWorld("ade", { getForLane: async (laneId: string): Promise => ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), listAll: async (): Promise => ipcRenderer.invoke(IPC.prsListAll), refresh: async (args: { prId?: string; prIds?: string[] } = {}): Promise => ipcRenderer.invoke(IPC.prsRefresh, args), - getStatus: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + getStatus: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetStatus, { prId }), getChecks: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetChecks, { prId }), getComments: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetComments, { prId }), getReviews: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetReviews, { prId }), diff --git a/apps/desktop/src/renderer/components/automations/components/BudgetCapEditor.tsx b/apps/desktop/src/renderer/components/automations/components/BudgetCapEditor.tsx index 0c00644f1..c56cd76cb 100644 --- a/apps/desktop/src/renderer/components/automations/components/BudgetCapEditor.tsx +++ b/apps/desktop/src/renderer/components/automations/components/BudgetCapEditor.tsx @@ -135,7 +135,7 @@ export function BudgetCapEditor({
Usage Guardrails diff --git a/apps/desktop/src/renderer/components/automations/components/CostSummaryCard.tsx b/apps/desktop/src/renderer/components/automations/components/CostSummaryCard.tsx index 999624f54..211246a6b 100644 --- a/apps/desktop/src/renderer/components/automations/components/CostSummaryCard.tsx +++ b/apps/desktop/src/renderer/components/automations/components/CostSummaryCard.tsx @@ -23,7 +23,7 @@ export function CostSummaryCard({
{provider} diff --git a/apps/desktop/src/renderer/components/automations/components/TemplateCard.tsx b/apps/desktop/src/renderer/components/automations/components/TemplateCard.tsx index bd207b3d7..5eb6d0de9 100644 --- a/apps/desktop/src/renderer/components/automations/components/TemplateCard.tsx +++ b/apps/desktop/src/renderer/components/automations/components/TemplateCard.tsx @@ -40,7 +40,7 @@ export function TemplateCard({
{template.name}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 8b0ae6022..3a47bf0c8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ComponentProps } from "react"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import { AgentChatComposer } from "./AgentChatComposer"; @@ -10,8 +10,8 @@ afterEach(cleanup); function renderComposer(overrides: Partial> = {}) { const props: ComponentProps = { - modelId: "openai/gpt-5-chat-latest", - availableModelIds: ["openai/gpt-5-chat-latest"], + modelId: "openai/gpt-5.4-codex", + availableModelIds: ["openai/gpt-5.4-codex"], reasoningEffort: null, draft: "Need a steer message", attachments: [], @@ -20,6 +20,7 @@ function renderComposer(overrides: Partial { expect(props.onClearDraft).not.toHaveBeenCalled(); }); - it("shows native Codex runtime controls", () => { - renderComposer(); + it("shows a single Claude mode row with hover details", () => { + renderComposer({ + sessionProvider: "claude", + modelId: "anthropic/claude-sonnet-4-6", + availableModelIds: ["anthropic/claude-sonnet-4-6"], + }); - expect(screen.getByRole("button", { name: "Plan" }).getAttribute("aria-pressed")).toBe("false"); - expect(screen.getByRole("button", { name: "Guarded edit" }).getAttribute("aria-pressed")).toBe("false"); - expect(screen.getByRole("button", { name: "Full auto" }).getAttribute("aria-pressed")).toBe("false"); - expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByRole("button", { name: "Chat" })).toBeNull(); + expect(screen.getByRole("button", { name: "Default" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Accept edits" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Bypass" })).toBeTruthy(); + expect(screen.getByText("Claude uses the normal approval flow for reads, edits, and tools.")).toBeTruthy(); + + fireEvent.mouseEnter(screen.getByRole("button", { name: "Plan" })); + expect(screen.getByText("Read-only Claude turns for analysis and implementation planning.")).toBeTruthy(); }); - it("maps Codex preset modes and reveals custom controls", () => { - const onCodexApprovalPolicyChange = vi.fn(); - const onCodexSandboxChange = vi.fn(); - const onCodexConfigSourceChange = vi.fn(); + it("routes Claude plan through both interaction and permission callbacks", () => { + const onInteractionModeChange = vi.fn(); + const onClaudePermissionModeChange = vi.fn(); renderComposer({ - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - onCodexApprovalPolicyChange, - onCodexSandboxChange, - onCodexConfigSourceChange, + sessionProvider: "claude", + modelId: "anthropic/claude-sonnet-4-6", + availableModelIds: ["anthropic/claude-sonnet-4-6"], + onInteractionModeChange, + onClaudePermissionModeChange, }); fireEvent.click(screen.getByRole("button", { name: "Plan" })); - expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); - expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("untrusted"); - expect(onCodexSandboxChange).toHaveBeenLastCalledWith("read-only"); - fireEvent.click(screen.getByRole("button", { name: "Guarded edit" })); - expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); - expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("on-failure"); - expect(onCodexSandboxChange).toHaveBeenLastCalledWith("workspace-write"); + expect(onInteractionModeChange).toHaveBeenCalledWith("plan"); + expect(onClaudePermissionModeChange).toHaveBeenCalledWith("plan"); + }); + + it("prefers the combined Claude mode callback when present", () => { + const onClaudeModeChange = vi.fn(); + const onInteractionModeChange = vi.fn(); + const onClaudePermissionModeChange = vi.fn(); + renderComposer({ + sessionProvider: "claude", + modelId: "anthropic/claude-sonnet-4-6", + availableModelIds: ["anthropic/claude-sonnet-4-6"], + onClaudeModeChange, + onInteractionModeChange, + onClaudePermissionModeChange, + }); - fireEvent.click(screen.getByRole("button", { name: "Full auto" })); - expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); - expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("never"); - expect(onCodexSandboxChange).toHaveBeenLastCalledWith("danger-full-access"); + fireEvent.click(screen.getByRole("button", { name: "Plan" })); - fireEvent.click(screen.getByRole("button", { name: "Custom" })); - expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("config-toml"); + expect(onClaudeModeChange).toHaveBeenCalledWith("plan"); + expect(onInteractionModeChange).not.toHaveBeenCalled(); + expect(onClaudePermissionModeChange).not.toHaveBeenCalled(); }); - it("shows the raw Codex controls in custom mode", () => { + it("shows preset-first Codex controls and a custom summary without raw selects", () => { renderComposer({ + sessionProvider: "codex", codexApprovalPolicy: "on-request", codexSandbox: "workspace-write", - codexConfigSource: "config-toml", + codexConfigSource: "flags", }); - expect(screen.getByDisplayValue("config.toml")).toBeTruthy(); - expect(screen.getByDisplayValue("On request")).toBeTruthy(); - expect(screen.getByDisplayValue("Workspace write")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Guarded edit" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Full auto" })).toBeTruthy(); + expect(screen.getByText("Custom")).toBeTruthy(); + expect(screen.queryByDisplayValue("ADE flags")).toBeNull(); + expect(screen.queryByDisplayValue("On request")).toBeNull(); + expect(screen.queryByDisplayValue("Workspace write")).toBeNull(); + expect(screen.getByText("Custom Codex mode: ADE flags · On request · Workspace write")).toBeTruthy(); + + fireEvent.mouseEnter(screen.getByRole("button", { name: "Full auto" })); + expect(screen.getByText(/Danger-full-access sandbox, approval policy: never/i)).toBeTruthy(); }); - it("enables native text assistance on the prompt textarea", () => { - renderComposer(); + it("maps Codex preset buttons to the underlying approval and sandbox controls", () => { + const onCodexPresetChange = vi.fn(); + renderComposer({ onCodexPresetChange }); - const textarea = screen.getByPlaceholderText("Steer the active turn..."); - expect(textarea.getAttribute("spellcheck")).toBe("true"); - expect(textarea.getAttribute("autocorrect")).toBe("on"); - expect(textarea.getAttribute("autocapitalize")).toBe("sentences"); + fireEvent.click(screen.getByRole("button", { name: "Full auto" })); + + expect(onCodexPresetChange).toHaveBeenCalledWith({ + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }); + }); + + it("disables attachments while steering an active turn", () => { + renderComposer({ turnActive: true }); + + expect((screen.getByTitle("Attach files or images (@)") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTitle("Upload file from disk") as HTMLButtonElement).disabled).toBe(true); }); it("opens the advanced popover and wires the advanced controls", () => { @@ -178,234 +215,4 @@ describe("AgentChatComposer", () => { fireEvent.click(screen.getByRole("button", { name: "Advanced" })); expect(screen.queryByText("Advanced settings")).toBeNull(); }); - - it("keeps the textarea text-assist attributes enabled by default", () => { - renderComposer(); - - const textarea = screen.getByRole("textbox"); - expect(textarea.getAttribute("spellcheck")).toBe("true"); - expect(textarea.getAttribute("autocorrect")).toBe("on"); - expect(textarea.getAttribute("autocapitalize")).toBe("sentences"); - }); - - it("keeps an unavailable API-only model visible in the selector", () => { - renderComposer({ - modelId: "openai/gpt-5.4-mini", - availableModelIds: ["openai/gpt-5.4"], - }); - - fireEvent.click(screen.getByRole("button", { name: "Select model" })); - fireEvent.click(screen.getByRole("button", { name: /^API\b/i })); - - const option = screen.getByRole("option", { name: /GPT-5\.4-Mini/i }); - expect(option.getAttribute("aria-disabled")).toBe("true"); - expect(option.textContent).toContain("API only · not configured"); - }); - - it("lists GPT-5.4-Mini in the OpenAI section even when it is unavailable", () => { - renderComposer({ - modelId: "openai/gpt-5.4", - availableModelIds: ["openai/gpt-5.4"], - }); - - fireEvent.click(screen.getByRole("button", { name: "Select model" })); - fireEvent.click(screen.getByRole("button", { name: /^API\b/i })); - - const option = screen.getByRole("option", { name: /GPT-5\.4-Mini/i }); - expect(option.getAttribute("aria-disabled")).toBe("true"); - expect(option.textContent).toContain("API only · not configured"); - }); - - /* ── Attachment picker tests ── */ - - it("opens the attachment picker when pressing @ in the textarea (turn inactive)", () => { - renderComposer({ turnActive: false, draft: "" }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - fireEvent.keyDown(textarea, { key: "@" }); - - expect(screen.getByPlaceholderText("Search files...")).toBeTruthy(); - }); - - it("does not open the attachment picker when pressing @ during an active turn", () => { - renderComposer({ turnActive: true, draft: "" }); - - const textarea = screen.getByPlaceholderText("Steer the active turn..."); - fireEvent.keyDown(textarea, { key: "@" }); - - expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); - }); - - it("searches for files via onSearchAttachments when typing in the picker", async () => { - vi.useFakeTimers(); - - const onSearchAttachments = vi.fn().mockResolvedValue([ - { path: "/project/src/index.ts", type: "file" }, - { path: "/project/src/app.tsx", type: "file" }, - ]); - renderComposer({ turnActive: false, draft: "", onSearchAttachments }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - fireEvent.keyDown(textarea, { key: "@" }); - - const searchInput = screen.getByPlaceholderText("Search files..."); - fireEvent.change(searchInput, { target: { value: "index" } }); - - // The search debounce is 120ms - await act(async () => { vi.advanceTimersByTime(150); }); - - expect(onSearchAttachments).toHaveBeenCalledWith("index"); - - vi.useRealTimers(); - - await waitFor(() => { - expect(screen.getByText("/project/src/index.ts")).toBeTruthy(); - expect(screen.getByText("/project/src/app.tsx")).toBeTruthy(); - }); - }); - - it("discards stale search results when a newer search completes first", async () => { - vi.useFakeTimers(); - - let resolveFirst!: (value: Array<{ path: string; type: "file" }>) => void; - let resolveSecond!: (value: Array<{ path: string; type: "file" }>) => void; - - const firstPromise = new Promise>((r) => { resolveFirst = r; }); - const secondPromise = new Promise>((r) => { resolveSecond = r; }); - - const onSearchAttachments = vi.fn() - .mockReturnValueOnce(firstPromise) - .mockReturnValueOnce(secondPromise); - - renderComposer({ turnActive: false, draft: "", onSearchAttachments }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - fireEvent.keyDown(textarea, { key: "@" }); - const searchInput = screen.getByPlaceholderText("Search files..."); - - // Type "old" and wait for debounce - fireEvent.change(searchInput, { target: { value: "old" } }); - await act(async () => { vi.advanceTimersByTime(150); }); - - // Type "new" and wait for debounce — this increments searchRequestIdRef - fireEvent.change(searchInput, { target: { value: "new" } }); - await act(async () => { vi.advanceTimersByTime(150); }); - - // The second (newer) search resolves first - await act(async () => { resolveSecond([{ path: "/project/new-result.ts", type: "file" }]); }); - - vi.useRealTimers(); - - await waitFor(() => { - expect(screen.getByText("/project/new-result.ts")).toBeTruthy(); - }); - - // Now the first (stale) search resolves — its results should be discarded - await act(async () => { resolveFirst([{ path: "/project/stale-result.ts", type: "file" }]); }); - - // Wait a tick to make sure no re-render happens with stale data - await waitFor(() => { - expect(screen.queryByText("/project/stale-result.ts")).toBeNull(); - expect(screen.getByText("/project/new-result.ts")).toBeTruthy(); - }); - }); - - it("selects an attachment from results and closes the picker", async () => { - vi.useFakeTimers(); - - const onAddAttachment = vi.fn(); - const onSearchAttachments = vi.fn().mockResolvedValue([ - { path: "/project/utils.ts", type: "file" }, - ]); - - renderComposer({ turnActive: false, draft: "", onAddAttachment, onSearchAttachments }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - fireEvent.keyDown(textarea, { key: "@" }); - - const searchInput = screen.getByPlaceholderText("Search files..."); - fireEvent.change(searchInput, { target: { value: "utils" } }); - await act(async () => { vi.advanceTimersByTime(150); }); - - vi.useRealTimers(); - - await waitFor(() => { - expect(screen.getByText("/project/utils.ts")).toBeTruthy(); - }); - - fireEvent.click(screen.getByText("/project/utils.ts")); - - expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/utils.ts", type: "file" }); - // Picker should close after selection - expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); - }); - - it("selects an attachment via Enter key on the highlighted result", async () => { - vi.useFakeTimers(); - - const onAddAttachment = vi.fn(); - const onSearchAttachments = vi.fn().mockResolvedValue([ - { path: "/project/alpha.ts", type: "file" }, - { path: "/project/beta.ts", type: "file" }, - ]); - - renderComposer({ turnActive: false, draft: "", onAddAttachment, onSearchAttachments }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - fireEvent.keyDown(textarea, { key: "@" }); - - const searchInput = screen.getByPlaceholderText("Search files..."); - fireEvent.change(searchInput, { target: { value: "project" } }); - await act(async () => { vi.advanceTimersByTime(150); }); - - vi.useRealTimers(); - - await waitFor(() => { - expect(screen.getByText("/project/alpha.ts")).toBeTruthy(); - }); - - // Move cursor down to second result and press Enter - fireEvent.keyDown(searchInput, { key: "ArrowDown" }); - fireEvent.keyDown(searchInput, { key: "Enter" }); - - expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/beta.ts", type: "file" }); - expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); - }); - - it("adds a file attachment via the hidden file input", async () => { - const onAddAttachment = vi.fn(); - renderComposer({ turnActive: false, draft: "", onAddAttachment }); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - - const file = new File(["content"], "a.ts", { type: "text/plain" }); - Object.defineProperty(file, "path", { value: "/project/a.ts", writable: false }); - - fireEvent.change(fileInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(onAddAttachment).toHaveBeenCalledTimes(1); - }); - - expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/a.ts", type: "file" }); - }); - - it("prevents submitting a whitespace-only message", () => { - const onSubmit = vi.fn(); - renderComposer({ turnActive: false, draft: " ", onSubmit, busy: false }); - - const textarea = screen.getByPlaceholderText("Message the assistant..."); - - // Try submitting via Enter - fireEvent.keyDown(textarea, { key: "Enter" }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("disables the Send button when draft is whitespace-only", () => { - renderComposer({ turnActive: false, draft: " \n\t " }); - - const sendButton = screen.getByTitle("Send"); - expect(sendButton.hasAttribute("disabled")).toBe(true); - }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index c047d7e67..d2f1de951 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { At, CaretDown, Image, Paperclip, Square, PaperPlaneTilt, Lightning } from "@phosphor-icons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { At, CaretDown, Image, Paperclip, Square, X, PaperPlaneTilt } from "@phosphor-icons/react"; import { inferAttachmentType, type AgentChatApprovalDecision, @@ -9,6 +9,7 @@ import { type AgentChatCodexSandbox, type AgentChatExecutionMode, type AgentChatFileRef, + type AgentChatInteractionMode, type AgentChatSlashCommand, type AgentChatUnifiedPermissionMode, type ComputerUseOwnerSnapshot, @@ -19,11 +20,15 @@ import { import { getModelById } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; +import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; +import { ChatStatusGlyph } from "./chatStatusVisuals"; import { ChatSubagentStrip } from "./ChatSubagentStrip"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; +const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; + type ExecutionModeOption = { value: AgentChatExecutionMode; label: string; @@ -46,10 +51,7 @@ const LOCAL_SLASH_COMMANDS: SlashCommandEntry[] = [ ]; /** Well-known defaults shown before the SDK session is initialized. */ -const CLAUDE_DEFAULT_COMMANDS: SlashCommandEntry[] = [ - { command: "/compact", label: "Compact", description: "Compact conversation history", source: "sdk" }, - { command: "/memory", label: "Memory", description: "View or edit CLAUDE.md", source: "sdk" }, -]; +const CLAUDE_DEFAULT_COMMANDS: SlashCommandEntry[] = []; const CODEX_DEFAULT_COMMANDS: SlashCommandEntry[] = [ { command: "/review", label: "Review", description: "Review uncommitted changes", source: "sdk" }, @@ -101,30 +103,26 @@ function buildSlashCommands(sdkCommands: AgentChatSlashCommand[], modelFamily?: return result; } -const CLAUDE_PERMISSION_OPTIONS: Array<{ value: AgentChatClaudePermissionMode; label: string }> = [ - { value: "default", label: "Default" }, - { value: "plan", label: "Plan" }, - { value: "acceptEdits", label: "Accept edits" }, - { value: "bypassPermissions", label: "Bypass" }, +const CLAUDE_MODE_OPTIONS: Array<{ value: AgentChatClaudePermissionMode; label: string; detail: string; safety: "safe" | "semi-auto" | "danger" }> = [ + { value: "default", label: "Default", detail: "Claude uses the normal approval flow for reads, edits, and tools.", safety: "safe" }, + { value: "plan", label: "Plan", detail: "Read-only Claude turns for analysis and implementation planning.", safety: "safe" }, + { value: "acceptEdits", label: "Accept edits", detail: "File edits are auto-approved; higher-risk actions still prompt.", safety: "semi-auto" }, + { value: "bypassPermissions", label: "Bypass", detail: "Skip Claude permission prompts for this chat.", safety: "danger" }, ]; -const CODEX_APPROVAL_OPTIONS: Array<{ value: AgentChatCodexApprovalPolicy; label: string }> = [ - { value: "untrusted", label: "Untrusted" }, - { value: "on-request", label: "On request" }, - { value: "on-failure", label: "On failure" }, - { value: "never", label: "Never" }, -]; +type CodexPermissionPreset = "plan" | "edit" | "full-auto" | "custom"; -const CODEX_SANDBOX_OPTIONS: Array<{ value: AgentChatCodexSandbox; label: string }> = [ - { value: "read-only", label: "Read only" }, - { value: "workspace-write", label: "Workspace write" }, - { value: "danger-full-access", label: "Danger full access" }, -]; - -const CODEX_CONFIG_SOURCE_OPTIONS: Array<{ value: AgentChatCodexConfigSource; label: string }> = [ - { value: "flags", label: "ADE flags" }, - { value: "config-toml", label: "config.toml" }, -]; +function resolveCodexPermissionPreset(args: { + codexApprovalPolicy?: AgentChatCodexApprovalPolicy; + codexSandbox?: AgentChatCodexSandbox; + codexConfigSource?: AgentChatCodexConfigSource; +}): CodexPermissionPreset { + if (args.codexConfigSource === "config-toml") return "custom"; + if (args.codexApprovalPolicy === "untrusted" && args.codexSandbox === "read-only") return "plan"; + if (args.codexApprovalPolicy === "on-failure" && args.codexSandbox === "workspace-write") return "edit"; + if (args.codexApprovalPolicy === "never" && args.codexSandbox === "danger-full-access") return "full-auto"; + return "custom"; +} const UNIFIED_PERMISSION_OPTIONS: Array<{ value: AgentChatUnifiedPermissionMode; label: string }> = [ { value: "plan", label: "Plan" }, @@ -132,40 +130,6 @@ const UNIFIED_PERMISSION_OPTIONS: Array<{ value: AgentChatUnifiedPermissionMode; { value: "full-auto", label: "Full auto" }, ]; -type CodexComposerMode = "plan" | "guarded-edit" | "full-auto" | "custom"; - -const CODEX_MODE_PRESETS: Record, { - approval: AgentChatCodexApprovalPolicy; - sandbox: AgentChatCodexSandbox; -}> = { - plan: { approval: "untrusted", sandbox: "read-only" }, - "guarded-edit": { approval: "on-failure", sandbox: "workspace-write" }, - "full-auto": { approval: "never", sandbox: "danger-full-access" }, -}; - -const CODEX_MODE_OPTIONS: Array<{ - value: CodexComposerMode; - label: string; - detail: string; -}> = [ - { value: "plan", label: "Plan", detail: "Read only" }, - { value: "guarded-edit", label: "Guarded edit", detail: "Safer edits" }, - { value: "full-auto", label: "Full auto", detail: "No prompts" }, - { value: "custom", label: "Custom", detail: "Use config.toml" }, -]; - -function resolveCodexComposerMode( - configSource: AgentChatCodexConfigSource | undefined, - approval: AgentChatCodexApprovalPolicy | undefined, - sandbox: AgentChatCodexSandbox | undefined, -): CodexComposerMode { - if (configSource === "config-toml") return "custom"; - if (approval === CODEX_MODE_PRESETS.plan.approval && sandbox === CODEX_MODE_PRESETS.plan.sandbox) return "plan"; - if (approval === CODEX_MODE_PRESETS["guarded-edit"].approval && sandbox === CODEX_MODE_PRESETS["guarded-edit"].sandbox) return "guarded-edit"; - if (approval === CODEX_MODE_PRESETS["full-auto"].approval && sandbox === CODEX_MODE_PRESETS["full-auto"].sandbox) return "full-auto"; - return "custom"; -} - type AdvancedSettingsPopoverProps = { executionModeOptions: ExecutionModeOption[]; executionMode: AgentChatExecutionMode | null; @@ -194,14 +158,15 @@ function AdvancedSettingsPopover({ onIncludeProjectDocsChange, }: AdvancedSettingsPopoverProps) { const [hoveredExecutionMode, setHoveredExecutionMode] = useState(null); + const activeBackend = computerUseSnapshot?.activeBackend?.name ?? (computerUsePolicy.allowLocalFallback ? "Fallback allowed" : "No fallback"); const activeExecutionMode = executionModeOptions.find((option) => option.value === executionMode) ?? executionModeOptions[0] ?? null; const helpMode = hoveredExecutionMode ? executionModeOptions.find((option) => option.value === hoveredExecutionMode) ?? activeExecutionMode : activeExecutionMode; return ( -
-
+
+
Advanced settings
@@ -330,7 +295,7 @@ function AdvancedSettingsPopover({
{helpMode ? ( -
+
Mode help {helpMode.label} @@ -345,10 +310,10 @@ function AdvancedSettingsPopover({ function ComputerUseSettingsModal({ open, - policy: _policy, + policy, snapshot, onClose, - onChange: _onChange, + onChange, onOpenProof, }: { open: boolean; @@ -370,8 +335,8 @@ function ComputerUseSettingsModal({ if (event.target === event.currentTarget) onClose(); }} > -
-
+
+
Computer use
@@ -431,6 +396,7 @@ export function AgentChatComposer({ sendOnEnter, busy, sessionProvider, + interactionMode, claudePermissionMode, codexApprovalPolicy, codexSandbox, @@ -456,7 +422,10 @@ export function AgentChatComposer({ onRemoveAttachment, onSearchAttachments, onExecutionModeChange, + onInteractionModeChange, + onClaudeModeChange, onClaudePermissionModeChange, + onCodexPresetChange, onCodexApprovalPolicyChange, onCodexSandboxChange, onCodexConfigSourceChange, @@ -481,6 +450,7 @@ export function AgentChatComposer({ sendOnEnter: boolean; busy: boolean; sessionProvider?: string; + interactionMode?: AgentChatInteractionMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; codexApprovalPolicy?: AgentChatCodexApprovalPolicy; codexSandbox?: AgentChatCodexSandbox; @@ -506,7 +476,14 @@ export function AgentChatComposer({ onRemoveAttachment: (path: string) => void; onSearchAttachments: (query: string) => Promise; onExecutionModeChange?: (mode: AgentChatExecutionMode) => void; + onInteractionModeChange?: (mode: AgentChatInteractionMode) => void; + onClaudeModeChange?: (mode: AgentChatClaudePermissionMode) => void; onClaudePermissionModeChange?: (mode: AgentChatClaudePermissionMode) => void; + onCodexPresetChange?: (next: { + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + }) => void; onCodexApprovalPolicyChange?: (policy: AgentChatCodexApprovalPolicy) => void; onCodexSandboxChange?: (sandbox: AgentChatCodexSandbox) => void; onCodexConfigSourceChange?: (source: AgentChatCodexConfigSource) => void; @@ -524,12 +501,14 @@ export function AgentChatComposer({ const [attachmentBusy, setAttachmentBusy] = useState(false); const [attachmentResults, setAttachmentResults] = useState([]); const [attachmentCursor, setAttachmentCursor] = useState(0); + const [attachError, setAttachError] = useState(null); const [slashPickerOpen, setSlashPickerOpen] = useState(false); const [slashQuery, setSlashQuery] = useState(""); const [slashCursor, setSlashCursor] = useState(0); + const [hoveredClaudeMode, setHoveredClaudeMode] = useState(null); + const [hoveredCodexPreset, setHoveredCodexPreset] = useState<"plan" | "edit" | "full-auto" | null>(null); - const [attachError, setAttachError] = useState(null); const [dragActive, setDragActive] = useState(false); const [advancedMenuOpen, setAdvancedMenuOpen] = useState(false); const [computerUseModalOpen, setComputerUseModalOpen] = useState(false); @@ -539,7 +518,6 @@ export function AgentChatComposer({ const textareaRef = useRef(null); const advancedMenuRef = useRef(null); const advancedButtonRef = useRef(null); - const searchRequestIdRef = useRef(0); const fileAddInProgressRef = useRef(false); const canAttach = !turnActive; @@ -619,22 +597,23 @@ export function AgentChatComposer({ setAttachmentCursor(0); return; } - const requestId = ++searchRequestIdRef.current; + let cancelled = false; const timeout = window.setTimeout(() => { setAttachmentBusy(true); onSearchAttachments(query) .then((results) => { - if (searchRequestIdRef.current !== requestId) return; + if (cancelled) return; setAttachmentResults(results.filter((r) => !attachedPaths.has(r.path))); setAttachmentCursor(0); }) - .catch(() => { if (searchRequestIdRef.current === requestId) setAttachmentResults([]); }) - .finally(() => { if (searchRequestIdRef.current === requestId) setAttachmentBusy(false); }); + .catch(() => { if (!cancelled) setAttachmentResults([]); }) + .finally(() => { if (!cancelled) setAttachmentBusy(false); }); }, 120); - return () => { searchRequestIdRef.current++; window.clearTimeout(timeout); }; + return () => { cancelled = true; window.clearTimeout(timeout); }; }, [attachmentPickerOpen, attachmentQuery, attachedPaths, onSearchAttachments]); const selectAttachment = (attachment: AgentChatFileRef) => { + setAttachError(null); onAddAttachment(attachment); setAttachmentPickerOpen(false); }; @@ -643,37 +622,38 @@ export function AgentChatComposer({ if (!canAttach || !files?.length) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; + setAttachError(null); try { for (const file of Array.from(files)) { const fileWithPath = file as File & { path?: string }; const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; if (hasRealPath) { - // File from filesystem (drag-drop from Finder, native picker) const filePath = fileWithPath.path!; onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); - } else { - // Clipboard paste or browser drag — no filesystem path. - // Read the blob, save to a temp file via IPC, then attach. - const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10 MB - if (file.size > MAX_BLOB_SIZE) { - setAttachError(`File "${file.name || "clipboard"}" is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum allowed size is 10 MB.`); - continue; - } - try { - const buf = await file.arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - const base64 = btoa(binary); - const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ - data: base64, - filename: file.name || "clipboard.png", - }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); - } catch { - // Silently skip files that can't be saved - } + continue; + } + + if (file.size > MAX_TEMP_ATTACHMENT_BYTES) { + setAttachError( + `File "${file.name || "clipboard"}" is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum allowed size is 10 MB.`, + ); + continue; + } + + try { + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + const base64 = btoa(binary); + const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ + data: base64, + filename: file.name || "clipboard.png", + }); + onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + } catch { + setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } } } finally { @@ -692,108 +672,223 @@ export function AgentChatComposer({ }; const nativeControlsDisabled = permissionModeLocked; - const codexMode = useMemo( - () => resolveCodexComposerMode(codexConfigSource, codexApprovalPolicy, codexSandbox), - [codexApprovalPolicy, codexConfigSource, codexSandbox], + const claudeSelectionMode = claudePermissionMode === "plan" || interactionMode === "plan" + ? "plan" + : claudePermissionMode ?? "default"; + const codexPreset = resolveCodexPermissionPreset({ + codexApprovalPolicy, + codexSandbox, + codexConfigSource, + }); + const codexPresetOptions = useMemo( + () => getPermissionOptions({ family: "openai", isCliWrapped: true }) + .filter((option) => option.value === "plan" || option.value === "edit" || option.value === "full-auto"), + [], ); - const showCodexFlagControls = sessionProvider === "codex" && codexMode === "custom"; + const applyCodexPreset = useCallback((preset: Exclude) => { + const next = preset === "plan" + ? { + codexApprovalPolicy: "untrusted" as const, + codexSandbox: "read-only" as const, + codexConfigSource: "flags" as const, + } + : preset === "edit" + ? { + codexApprovalPolicy: "on-failure" as const, + codexSandbox: "workspace-write" as const, + codexConfigSource: "flags" as const, + } + : { + codexApprovalPolicy: "never" as const, + codexSandbox: "danger-full-access" as const, + codexConfigSource: "flags" as const, + }; + + if (onCodexPresetChange) { + onCodexPresetChange(next); + return; + } + onCodexConfigSourceChange?.(next.codexConfigSource); + onCodexApprovalPolicyChange?.(next.codexApprovalPolicy); + onCodexSandboxChange?.(next.codexSandbox); + }, [ + onCodexApprovalPolicyChange, + onCodexConfigSourceChange, + onCodexPresetChange, + onCodexSandboxChange, + ]); + const claudeControlDetail = useMemo(() => { + if (sessionProvider !== "claude") return null; + const option = CLAUDE_MODE_OPTIONS.find((item) => item.value === (hoveredClaudeMode ?? claudeSelectionMode)); + return option?.detail ?? null; + }, [claudeSelectionMode, hoveredClaudeMode, sessionProvider]); + const codexCustomSummary = useMemo(() => { + if (sessionProvider !== "codex" || codexPreset !== "custom") return null; + if (codexConfigSource === "config-toml") { + return "Custom Codex mode: config.toml controls approval and sandbox."; + } + const approvalLabel = { + "untrusted": "Plan", + "on-request": "On request", + "on-failure": "Guarded edit", + "never": "Full auto", + }[codexApprovalPolicy ?? "on-request"]; + const sandboxLabel = { + "read-only": "Read only", + "workspace-write": "Workspace write", + "danger-full-access": "Danger full access", + }[codexSandbox ?? "workspace-write"]; + return `Custom Codex mode: ${codexConfigSource === "flags" ? "ADE flags" : "config.toml"} · ${approvalLabel} · ${sandboxLabel}`; + }, [codexApprovalPolicy, codexConfigSource, codexPreset, codexSandbox, sessionProvider]); + const codexControlDetail = useMemo(() => { + if (sessionProvider !== "codex") return null; + if (hoveredCodexPreset) { + return codexPresetOptions.find((option) => option.value === hoveredCodexPreset)?.detail ?? null; + } + if (codexPreset === "custom") { + return codexCustomSummary; + } + return codexPresetOptions.find((option) => option.value === codexPreset)?.detail ?? null; + }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sessionProvider]); const nativeControlPanel = useMemo(() => { - const renderSelect = ( + const renderButtonGroup = ( label: string, value: T | undefined, - options: Array<{ value: T; label: string }>, + options: Array<{ value: T; label: string; detail: string; safety?: "safe" | "semi-auto" | "danger" }>, onChange: ((value: T) => void) | undefined, disabled = false, + onHoverChange?: (value: T | null) => void, ) => ( - +
+ {label} +
+ {options.map((option) => { + const active = value === option.value; + const colors = option.safety ? safetyColors(option.safety) : null; + return ( + + ); + })} +
+
); if (sessionProvider === "claude") { - return renderSelect("Claude", claudePermissionMode, CLAUDE_PERMISSION_OPTIONS, onClaudePermissionModeChange, nativeControlsDisabled); + return ( +
+ {renderButtonGroup("Claude", claudeSelectionMode, CLAUDE_MODE_OPTIONS, (mode) => { + if (onClaudeModeChange) { + onClaudeModeChange(mode); + return; + } + if (mode === "plan") { + onInteractionModeChange?.("plan"); + onClaudePermissionModeChange?.("plan"); + return; + } + onInteractionModeChange?.("default"); + onClaudePermissionModeChange?.(mode); + }, nativeControlsDisabled, setHoveredClaudeMode)} +
+ ); } - const setCodexMode = (mode: CodexComposerMode) => { - if (nativeControlsDisabled) return; - if (!onCodexConfigSourceChange || !onCodexApprovalPolicyChange || !onCodexSandboxChange) return; - - if (mode === "custom") { - onCodexConfigSourceChange("config-toml"); - return; - } - - const preset = CODEX_MODE_PRESETS[mode]; - onCodexConfigSourceChange("flags"); - onCodexApprovalPolicyChange(preset.approval); - onCodexSandboxChange(preset.sandbox); - }; - if (sessionProvider === "codex") { return ( -
-
- {CODEX_MODE_OPTIONS.map((option) => { - const isActive = codexMode === option.value; +
+
+ {codexPresetOptions.map((option) => { + const active = codexPreset === option.value; + const colors = safetyColors(option.safety); return ( ); })} -
- {showCodexFlagControls ? ( -
- {renderSelect("Config", codexConfigSource, CODEX_CONFIG_SOURCE_OPTIONS, onCodexConfigSourceChange, nativeControlsDisabled)} - {renderSelect("Approval", codexApprovalPolicy, CODEX_APPROVAL_OPTIONS, onCodexApprovalPolicyChange, nativeControlsDisabled)} - {renderSelect("Sandbox", codexSandbox, CODEX_SANDBOX_OPTIONS, onCodexSandboxChange, nativeControlsDisabled)} +
+ Custom
- ) : null} +
); } - return renderSelect("ADE", unifiedPermissionMode, UNIFIED_PERMISSION_OPTIONS, onUnifiedPermissionModeChange, nativeControlsDisabled); + return ( + + ); }, [ + claudeSelectionMode, claudePermissionMode, - codexMode, - codexApprovalPolicy, + applyCodexPreset, + codexPreset, + codexPresetOptions, + codexCustomSummary, codexConfigSource, - codexSandbox, + hoveredClaudeMode, + hoveredCodexPreset, nativeControlsDisabled, + onClaudeModeChange, onClaudePermissionModeChange, - onCodexApprovalPolicyChange, - onCodexConfigSourceChange, - onCodexSandboxChange, + onInteractionModeChange, onUnifiedPermissionModeChange, sessionProvider, - showCodexFlagControls, unifiedPermissionMode, ]); /* ── Keyboard handler for textarea ── */ @@ -887,34 +982,67 @@ export function AgentChatComposer({ mode={surfaceMode} className="m-3 mt-0 rounded-[var(--chat-radius-shell)]" pendingBanner={pendingInput ? ( -
-
- - - - - {pendingInput.kind === "approval" || pendingInput.kind === "permissions" ? "Approval" : "Input needed"} · {pendingInput.source} - -
-
- {pendingInput.description ?? pendingInput.questions[0]?.question ?? "The agent is waiting for input."} -
- {pendingInput.kind === "approval" || pendingInput.kind === "permissions" ? ( + pendingInput.kind === "plan_approval" ? ( +
+
+ + + + + Plan Approval · {pendingInput.source} + +
+
+ {pendingInput.description ?? pendingInput.questions[0]?.question ?? "The agent has prepared a plan."} +
- - - + +
- ) : ( -
- Open the question modal to answer and continue. +
+ ) : ( +
+
+ + + + + {pendingInput.kind === "approval" || pendingInput.kind === "permissions" ? "Approval" : "Input needed"} · {pendingInput.source} +
- )} -
+
+ {pendingInput.description ?? pendingInput.questions[0]?.question ?? "The agent is waiting for input."} +
+ {pendingInput.kind === "approval" || pendingInput.kind === "permissions" ? ( +
+ + + +
+ ) : ( +
+ Open the question modal to answer and continue. +
+ )} +
+ ) ) : undefined} trays={ - attachments.length || subagentSnapshots.length ? ( + attachments.length || subagentSnapshots.length || attachError ? (
+ {attachError ? ( +
+ {attachError} + +
+ ) : null} {subagentSnapshots.length ? ( ) : null} - {attachError ? ( -
{attachError}
- ) : null} {slashPickerOpen && filteredSlashCommands.length > 0 ? ( -
-
+
+
Commands
@@ -957,7 +1082,7 @@ export function AgentChatComposer({ key={cmd.command} type="button" className={cn( - "flex w-full items-center gap-3 px-3 py-2 text-left font-sans text-[10px]", + "flex w-full items-center gap-3 px-3 py-2 text-left font-mono text-[10px]", index === slashCursor ? "bg-accent/10 text-fg" : "text-fg/55 hover:bg-border/6", )} onMouseEnter={() => setSlashCursor(index)} @@ -975,15 +1100,15 @@ export function AgentChatComposer({ ) : null} {attachmentPickerOpen ? ( -
-
+
+
setAttachmentQuery(e.target.value)} placeholder="Search files..." - className="h-5 flex-1 bg-transparent font-sans text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/25" + className="h-5 flex-1 bg-transparent font-mono text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/25" onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setAttachmentPickerOpen(false); return; } if (event.key === "ArrowDown") { event.preventDefault(); setAttachmentCursor((v) => Math.min(v + 1, Math.max(attachmentResults.length - 1, 0))); return; } @@ -997,16 +1122,16 @@ export function AgentChatComposer({
{!attachmentQuery.trim().length ? ( -
Type to search files...
+
Type to search files...
) : attachmentBusy ? ( -
Searching...
+
Searching...
) : attachmentResults.length ? ( attachmentResults.map((result, index) => ( )) ) : ( -
No matching files.
+
No matching files.
)}
@@ -1025,53 +1150,66 @@ export function AgentChatComposer({ } footer={ -
-
+
+
-
{nativeControlPanel}
-
- -
+ {nativeControlPanel}
-
-
- - - -
+
+ +
+
+ {claudeControlDetail ? ( +
+ {claudeControlDetail} +
+ ) : null} + {codexControlDetail ? ( +
+ {codexControlDetail} +
+ ) : null} + +
+
+ + + +
+
@@ -1188,7 +1326,7 @@ export function AgentChatComposer({ > {promptSuggestion} - + Tab @@ -1204,15 +1342,12 @@ export function AgentChatComposer({ if (val.startsWith("/")) { setSlashQuery(val.slice(1)); setSlashCursor(0); } }} className={cn( - "min-h-[40px] max-h-[160px] w-full resize-none bg-transparent px-4 py-3 font-sans text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + "min-h-[40px] max-h-[40vh] w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", dragActive ? "opacity-30" : "", )} placeholder={turnActive ? "Steer the active turn..." : (promptSuggestion ? "" : (messagePlaceholder ?? "Message the assistant..."))} onKeyDown={handleKeyDown} onPaste={handlePaste} - spellCheck - autoCorrect="on" - autoCapitalize="sentences" />
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 3d70389d6..9a9bd0870 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -4,7 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import type { AgentChatEventEnvelope } from "../../../shared/types"; -import { AgentChatMessageList } from "./AgentChatMessageList"; +import * as modelRegistry from "../../../shared/modelRegistry"; +import { + AgentChatMessageList, + calculateVirtualWindow, + deriveTurnModelState, + reconcileMeasuredScrollTop, +} from "./AgentChatMessageList"; function findButtonByTextContent(matcher: RegExp): HTMLButtonElement { const match = screen.getAllByRole("button").find((button) => matcher.test(button.textContent ?? "")); @@ -68,6 +74,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); if (originalAde === undefined) { delete (globalThis.window as any).ade; } else { @@ -424,6 +431,44 @@ describe("AgentChatMessageList transcript rendering", () => { expect(findButtonByTextContent(/echo ok/i)).toBeTruthy(); }); + it("recomputes virtualization windows when measured heights change", () => { + const baseline = calculateVirtualWindow({ + rowCount: 100, + scrollTop: 2000, + containerHeight: 240, + rowHeight: () => 80, + }); + const updated = calculateVirtualWindow({ + rowCount: 100, + scrollTop: 2000, + containerHeight: 240, + rowHeight: (index) => (index === 0 ? 180 : 80), + }); + + expect(updated.totalHeight).toBeGreaterThan(baseline.totalHeight); + expect(updated.offsetTop).toBeGreaterThan(baseline.offsetTop); + }); + + it("keeps the current viewport anchored when rows above it grow", () => { + const adjusted = reconcileMeasuredScrollTop({ + index: 2, + previousHeight: 80, + nextHeight: 140, + scrollTop: 400, + rowHeight: () => 80, + }); + const unchanged = reconcileMeasuredScrollTop({ + index: 8, + previousHeight: 80, + nextHeight: 140, + scrollTop: 400, + rowHeight: () => 80, + }); + + expect(adjusted).toBe(460); + expect(unchanged).toBe(400); + }); + it("keeps activity rows in the streaming indicator instead of the transcript", () => { const sharedEvents: AgentChatEventEnvelope[] = [ { @@ -566,7 +611,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByTestId("location").textContent).toBe("/files::{\"laneId\":\"lane-123\"}"); }); - it("renders ask-user requests with a static amber dot instead of a spinner", () => { + it("renders ask-user requests with an amber waiting spinner", () => { const view = renderMessageList([ { sessionId: "session-1", @@ -586,8 +631,7 @@ describe("AgentChatMessageList transcript rendering", () => { ]); expect(screen.getByText("Needs Input")).toBeTruthy(); - expect(view.container.querySelector(".animate-spin")).toBeNull(); - expect(view.container.querySelector(".bg-amber-400\\/85")).toBeTruthy(); + expect(view.container.querySelector(".animate-spin.text-amber-400")).toBeTruthy(); }); it("labels provider chats as Codex and preserves explicit assistant labels", () => { @@ -763,3 +807,47 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByText("First thought.Second thought.")).toBeNull(); }); }); + +describe("deriveTurnModelState", () => { + it("only processes newly appended done events when history grows", () => { + const getModelByIdSpy = vi.spyOn(modelRegistry, "getModelById").mockReturnValue({ + displayName: "Codex", + } as any); + const firstBatch: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "done", + turnId: "turn-1", + status: "completed", + modelId: "gpt-5.4-codex", + }, + }, + ]; + + const initialState = deriveTurnModelState(firstBatch); + expect(initialState.map.get("turn-1")?.label).toContain("Codex"); + expect(getModelByIdSpy).toHaveBeenCalledTimes(1); + + const nextState = deriveTurnModelState( + [ + ...firstBatch, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "done", + turnId: "turn-2", + status: "completed", + modelId: "gpt-5.4-codex", + }, + }, + ], + initialState, + ); + + expect(nextState.map.get("turn-2")?.label).toContain("Codex"); + expect(getModelByIdSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 06e9dc0a9..9ae0594fa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useLocation, useNavigate } from "react-router-dom"; @@ -11,7 +10,6 @@ import { FileCode, CheckCircle, XCircle, - SpinnerGap, Circle, Checks, ListChecks, @@ -47,10 +45,9 @@ import { getToolMeta } from "./chatToolAppearance"; import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatWorkLogBlock } from "./ChatWorkLogBlock"; -import { HighlightedCode } from "./CodeHighlighter"; +import { ChatStatusGlyph } from "./chatStatusVisuals"; import { collapseChatTranscriptEventsIncremental, - deriveTurnDividerData, formatStructuredValue, groupConsecutiveWorkLogRows, readRecord, @@ -59,7 +56,6 @@ import { type ChatTranscriptGroupedEnvelope as TranscriptGroupedEnvelope, type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope, } from "./chatTranscriptRows"; -import { ChatTurnDivider, type TurnDividerData } from "./ChatTurnDivider"; const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); @@ -129,6 +125,72 @@ function formatFileAction(kind: Extract } } +function hasNoticeDetail(detail: string | AgentChatNoticeDetail | undefined): boolean { + if (detail == null) return false; + if (typeof detail === "string") return detail.trim().length > 0; + return Boolean( + detail.title?.trim() + || detail.summary?.trim() + || detail.metrics?.length + || detail.sections?.length, + ); +} + +function renderNoticeDetail(detail: string | AgentChatNoticeDetail): React.ReactNode { + if (typeof detail === "string") { + return
{detail}
; + } + + return ( +
+ {detail.title?.trim() ?
{detail.title.trim()}
: null} + {detail.summary?.trim() ?
{detail.summary.trim()}
: null} + {detail.metrics?.length ? ( +
+ {detail.metrics.map((metric) => ( +
+
{metric.label}
+
+ {metric.value} +
+
+ ))} +
+ ) : null} + {detail.sections?.map((section) => ( +
+
{section.title}
+
+ {section.items.map((item, index) => ( + typeof item === "string" ? ( +
+ {item} +
+ ) : ( +
+ {item.label} + + {item.value} + +
+ ) + ))} +
+
+ ))} +
+ ); +} + function formatTokenCount(value: number | null | undefined): string | null { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; @@ -158,13 +220,13 @@ function renderSubagentUsage(usage: { } const GLASS_CARD_CLASS = - "overflow-hidden rounded-[14px] border border-[color:var(--chat-card-border)] bg-[var(--chat-card-bg)] shadow-[var(--chat-card-shadow)]"; + "overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#121216]"; const WORK_LOG_CARD_CLASS = - "border border-[color:var(--chat-panel-border)] bg-[var(--chat-panel-bg)]/88"; + "border border-white/[0.06] bg-[#111317]/70"; const RECESSED_BLOCK_CLASS = - "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-[color:var(--chat-code-border)] bg-[var(--chat-code-bg)] px-4 py-3 font-mono text-[11px] leading-[1.6] text-[var(--chat-code-fg)]"; + "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-white/[0.05] bg-[#09090b] px-4 py-3 font-mono text-[11px] leading-[1.6] text-fg/76"; function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChipTone } | null { if (toolName.startsWith("mcp__")) { @@ -185,93 +247,25 @@ function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChi return null; } -const MESSAGE_CARD_STYLE: React.CSSProperties = { - borderColor: "var(--chat-card-border)", - background: "var(--chat-card-bg)", -}; - -const SURFACE_INLINE_CARD_STYLE: React.CSSProperties = { - borderColor: "var(--chat-panel-border)", - background: "var(--chat-panel-bg)", -}; - -const ASSISTANT_MESSAGE_CARD_STYLE: React.CSSProperties = { - borderColor: "var(--chat-panel-border)", - background: "var(--chat-panel-bg-strong)", -}; - -function renderNoticeDetailMetric(metric: { - label: string; - value: string; - tone?: ChatSurfaceChipTone; -}) { - return ( -
-
- {metric.label} -
-
- {metric.value} -
-
- ); +function messageCardStyle(): React.CSSProperties { + return { + borderColor: "rgba(245, 158, 11, 0.16)", + background: "#171412", + }; } -function renderNoticeDetailSectionItem(item: string | { label: string; value: string; tone?: ChatSurfaceChipTone }) { - if (typeof item === "string") { - return
{item}
; - } - return renderNoticeDetailMetric(item); +function surfaceInlineCardStyle(): React.CSSProperties { + return { + borderColor: "rgba(255, 255, 255, 0.08)", + background: "#14161a", + }; } -function renderNoticeDetail(detail: string | AgentChatNoticeDetail) { - if (typeof detail === "string") { - return
{detail}
; - } - - const sections = detail.sections ?? []; - const hasAdditionalDetail = Boolean(detail.metrics?.length || sections.length); - - return ( -
- {detail.title ? ( -
- {detail.title} -
- ) : null} - {detail.summary && !hasAdditionalDetail ? ( -
- {detail.summary} -
- ) : null} - {detail.metrics?.length ? ( -
- {detail.metrics.map((metric) => renderNoticeDetailMetric(metric))} -
- ) : null} - {sections.length ? ( -
- {sections.map((section) => ( -
-
- {section.title} -
-
- {section.items.map((item, index) => ( -
- {renderNoticeDetailSectionItem(item)} -
- ))} -
-
- ))} -
- ) : null} -
- ); +function assistantMessageCardStyle(): React.CSSProperties { + return { + borderColor: "rgba(148, 163, 184, 0.14)", + background: "#101318", + }; } function describeUserDeliveryState(event: Extract): { label: string; className: string } | null { @@ -358,9 +352,8 @@ function MessageCopyButton({ /* ── Status indicators ── */ function StatusIcon({ status }: { status: "running" | "completed" | "failed" }) { - if (status === "completed") return ; - if (status === "failed") return ; - return ; + if (status === "completed" || status === "failed") return ; + return ; } function PlanStepIcon({ status }: { status: string }) { @@ -387,8 +380,10 @@ function statusColorClass(status: string | undefined): string { return "text-emerald-400/70"; case "failed": return "text-red-400/70"; + case "running": + return "text-amber-400/70"; default: - return "text-accent/70"; + return "text-emerald-400/70"; } } @@ -476,7 +471,7 @@ function InlineDisclosureRow({
{summary}
{expandable && open ? ( -
+
{children}
) : null} @@ -552,15 +547,18 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ }, [onOpenWorkspacePath, workspaceLaneId]); return ( -
+

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , blockquote: ({ children }) => ( -
    +
    {children}
    ), @@ -569,30 +567,19 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ {children}
    ), - pre: ({ children }) => { - // When code blocks are handled by HighlightedCode, skip the default
     wrapper
    -            // since HighlightedCode provides its own styled container.
    -            const child = React.Children.toArray(children)[0];
    -            if (React.isValidElement(child) && (child as React.ReactElement).type === HighlightedCode) {
    -              return <>{children};
    -            }
    -            return (
    -              
    -                {children}
    -              
    - ); - }, + pre: ({ children }) => ( +
    +              {children}
    +            
    + ), code: ({ className, children }) => { const text = String(children ?? ""); - const langMatch = typeof className === "string" ? className.match(/language-(\S+)/) : null; - const isBlock = /\n/.test(text) || langMatch != null; + const isBlock = /\n/.test(text) || (typeof className === "string" && className.length > 0); const workspacePath = !isBlock ? normalizeWorkspacePathCandidate(text) : null; const pathIsClickable = Boolean(workspacePath && looksLikeWorkspacePath(workspacePath)); - if (isBlock) { - const language = langMatch?.[1] ?? "text"; - return ; - } - return pathIsClickable ? ( + return isBlock ? ( + {children} + ) : pathIsClickable ? ( ) : ( - {children} + {children} ); }, a: ({ children, href }) => { @@ -670,16 +657,16 @@ function CollapsibleCard({ const isOpen = forceOpen === true ? true : open; return ( -
    +
    - {isOpen ?
    {children}
    : null} + {isOpen ?
    {children}
    : null}
    ); } @@ -721,12 +708,10 @@ const ACTIVITY_LABELS: Record = { running_command: "Running command", searching: "Searching", reading: "Reading", - tool_calling: "Calling tool", - web_searching: "Web searching", - spawning_agent: "Spawning agent" + tool_calling: "Calling tool" }; -function ThinkingDots({ toneClass = "bg-fg/30" }: { toneClass?: string }) { +function ThinkingDots({ toneClass = "bg-emerald-300/70" }: { toneClass?: string }) { return (