From d41a9d9ef830468e176175145d794610ccc4f56f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:26:01 -0400 Subject: [PATCH 1/3] Refs ADE-136: ship: checkpoint ADE-136 CTO follow-ups --- .../src/headlessLinearServices.test.ts | 1 + apps/ade-cli/src/headlessLinearServices.ts | 5 +- .../src/main/services/adeActions/registry.ts | 2 +- .../services/ai/tools/ctoOperatorTools.ts | 3 +- .../services/chat/agentChatService.test.ts | 22 +++++- .../main/services/chat/agentChatService.ts | 70 +++++++++---------- .../src/main/services/chat/claudeSdkCompat.ts | 32 +++++++++ .../src/main/services/cto/ctoPromptContent.ts | 56 +-------------- .../src/main/services/cto/ctoState.test.ts | 17 ++--- .../src/main/services/ipc/registerIpc.ts | 2 +- .../services/sync/syncHostService.test.ts | 4 +- .../sync/syncRemoteCommandService.test.ts | 12 +++- apps/desktop/src/renderer/browserMock.ts | 1 + .../renderer/hooks/useCtoAttention.test.tsx | 62 ++++++++++++++++ .../src/renderer/hooks/useCtoAttention.ts | 3 +- apps/desktop/src/renderer/state/appStore.ts | 1 + .../adapter/__tests__/adapter.test.ts | 24 +++++++ .../src/renderer/webclient/adapter/misc.ts | 6 ++ apps/desktop/src/shared/types/cto.ts | 19 +++-- apps/ios/ADE/App/ContentView.swift | 4 +- apps/ios/ADE/Models/RemoteModels.swift | 25 ++++++- apps/ios/ADE/Services/SyncService.swift | 11 ++- apps/ios/ADETests/ADETests.swift | 25 ++++++- docs/ARCHITECTURE.md | 9 ++- docs/features/chat/README.md | 1 + docs/features/cto/README.md | 31 ++++---- .../sync-and-multi-device/ios-companion.md | 2 +- .../sync-and-multi-device/remote-commands.md | 6 +- 28 files changed, 308 insertions(+), 148 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/claudeSdkCompat.ts create mode 100644 apps/desktop/src/renderer/hooks/useCtoAttention.test.tsx diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 0ab4a831f..83ccd233e 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -612,6 +612,7 @@ describe("headlessLinearServices", () => { const session = await services.agentChatService.createSession({ laneId: "lane-1" }); expect(await services.agentChatService.getCtoAttention()).toEqual({ + status: "idle", awaitingInput: false, since: null, }); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index e316af14e..ba6c0a695 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -33,6 +33,7 @@ import type { GitHubRepoRef, GitHubRateLimitState, GitHubStatus, + CtoAttentionState, } from "../../desktop/src/shared/types"; import type { GithubService, @@ -188,7 +189,7 @@ type HeadlessLinearServices = { sessionId: string, ) => Promise | null>; /** Mirrors the desktop chat service so `cto_state.getAttention` resolves headlessly. */ - getCtoAttention: () => Promise<{ awaitingInput: boolean; since: string | null }>; + getCtoAttention: () => Promise; getChatTranscript: (args: { sessionId: string; limit?: number; @@ -2707,7 +2708,7 @@ function createHeadlessAgentChatService( // `ade actions run cto_state.getAttention` throws a TypeError. Headless // sessions never block on user input — there is no turn loop to block — // so "not waiting" is the truthful answer, not a placeholder. - return { awaitingInput: false, since: null }; + return { status: "idle", awaitingInput: false, since: null }; }, async getChatTranscript({ sessionId, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b615646da..2d79814e8 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1863,7 +1863,7 @@ function buildCtoStateDomainService(runtime: AdeRuntime): OpaqueService | null { */ getAttention: async (): Promise => (await runtime.agentChatService?.getCtoAttention()) - ?? { awaitingInput: false, since: null }, + ?? { status: "unknown", awaitingInput: false, since: null }, }; } diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 4688877fb..9064a7a7e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -13,6 +13,7 @@ import type { AutomationRunListArgs, GitPullArgs, OperatorNavigationSuggestion, + PtyCreateArgs, SessionSettleOverride, SessionWakeReason, TestRunSummary, @@ -62,7 +63,7 @@ export interface CtoOperatorToolDeps { getLogTail: (args: { runId: string; maxBytes?: number }) => string; } | null; ptyService?: { - create: (args: { laneId: string; title?: string; cols?: number; rows?: number; tracked?: boolean; toolType?: "shell"; startupCommand?: string }) => Promise<{ ptyId: string; sessionId: string }>; + create: (args: PtyCreateArgs) => Promise<{ ptyId: string; sessionId: string }>; } | null; automationService?: { list: () => AutomationRuleSummary[]; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f5fd93b5d..8899aa68b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -8041,7 +8041,7 @@ describe("createAgentChatService", () => { const before = sessionService.list({}).length; const attention = await service.getCtoAttention(); - expect(attention).toEqual({ awaitingInput: false, since: null }); + expect(attention).toEqual({ status: "idle", awaitingInput: false, since: null }); // The invariant that matters: drawing a badge must not materialize a // lane and a chat session as a side effect. expect(sessionService.list({}).length).toBe(before); @@ -8054,7 +8054,7 @@ describe("createAgentChatService", () => { const { service, sessionService } = createService({ ctoStateService, ctoMemoryService }); const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); - expect(await service.getCtoAttention()).toEqual({ awaitingInput: false, since: null }); + expect(await service.getCtoAttention()).toEqual({ status: "idle", awaitingInput: false, since: null }); // `ade chat ask` raises a hand on the backing session row — a separate // signal from the chat-level `awaitingInput` waiter, and the one a @@ -8064,6 +8064,7 @@ describe("createAgentChatService", () => { row.attentionRequestedAt = new Date().toISOString(); const attention = await service.getCtoAttention(); + expect(attention.status).toBe("awaiting-input"); expect(attention.awaitingInput).toBe(true); expect(attention.since).toBeTruthy(); @@ -8083,11 +8084,28 @@ describe("createAgentChatService", () => { row.attentionRequestedAt = null; const attention = await service.getCtoAttention(); + expect(attention.status).toBe("idle"); expect(attention.awaitingInput).toBe(false); expect(attention.since).toBeNull(); db.close(); }); + + it("reports unknown instead of falsely clearing when the session scan fails", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); + const { service, sessionService } = createService({ ctoStateService, ctoMemoryService }); + vi.spyOn(sessionService, "list").mockImplementationOnce(() => { + throw new Error("temporary session store failure"); + }); + + await expect(service.getCtoAttention()).resolves.toEqual({ + status: "unknown", + awaitingInput: false, + since: null, + }); + + db.close(); + }); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9aaec24d0..a8a45dc1b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -49,6 +49,10 @@ import { z, type ZodType } from "zod"; import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { listPromptStashAttachmentPaths } from "./promptStashService"; import { ClaudeInputPump } from "./claudeInputPump"; +import { + normalizeClaudeInterruptReceipt, + normalizeClaudeRewindSkippedLinks, +} from "./claudeSdkCompat"; import { createClaudeStructuredActivityState, finalizeClaudeStructuredActivities, @@ -8384,12 +8388,7 @@ export function createAgentChatService(args: { reuseExisting, permissionMode: "full-auto", }), - // The cast covers one remaining structural gap: `CtoOperatorToolDeps` - // restates `ptyService.create` with optional cols/rows/title while the real - // `PtyCreateArgs` requires them (the pty service clamps to 80x24). Harmless - // while these tool bodies were unreachable; now that they execute, it is - // worth reconciling — tracked as a follow-up rather than widened blind here. - } as Parameters[0]; + }; }; /** @@ -13262,12 +13261,7 @@ export function createAgentChatService(args: { stopMode: AgentChatStopMode = "stop_and_clear", ): void => { if (!response) return; - const stillQueuedUuids = (Array.isArray(response.still_queued) ? response.still_queued : []) - .filter((uuid): uuid is string => typeof uuid === "string" && uuid.trim().length > 0) - .map((uuid) => uuid.trim()); - const cancelledUuids = (Array.isArray(response.cancelled) ? response.cancelled : []) - .filter((uuid): uuid is string => typeof uuid === "string" && uuid.trim().length > 0) - .map((uuid) => uuid.trim()); + const { stillQueuedUuids, cancelledUuids } = normalizeClaudeInterruptReceipt(response); if (!stillQueuedUuids.length && !cancelledUuids.length) return; emitChatEvent(managed, { type: "interrupt_receipt", @@ -15685,14 +15679,6 @@ export function createAgentChatService(args: { return lease; }; - const ensureOrchestrationHttpMcpServer = ( - managed: ManagedChatSession, - ): Promise => ensureHttpMcpServer(managed, "orchestration"); - - const ensureCtoHttpMcpServer = ( - managed: ManagedChatSession, - ): Promise => ensureHttpMcpServer(managed, "cto"); - /** * Resolves every tool set that has a live HTTP lease, in table order. The * per-SDK config shapes differ (record-of-http, record-of-remote, array), so @@ -36697,7 +36683,9 @@ export function createAgentChatService(args: { }); } } - const preserveQueryForQueuedMessages = (interruptResponse?.still_queued?.length ?? 0) > 0; + const normalizedInterrupt = normalizeClaudeInterruptReceipt(interruptResponse); + const preserveQueryForQueuedMessages = normalizedInterrupt.stillQueuedUuids.length > 0; + const providerCancelledUuids = normalizedInterrupt.cancelledUuids; if (!preserveQueryForQueuedMessages) { // Invalidate the idle reader and any already-issued `next()` promise as // part of the same reset. Clearing only query/inputPump lets that stale @@ -36706,8 +36694,8 @@ export function createAgentChatService(args: { } if (mode === "stop_and_clear") { const localQueuedCount = localQueuedForRecovery.length; - result.cancelledQueuedCount = localQueuedCount + (interruptResponse?.cancelled?.length ?? 0); - const providerCancelledSteers = (interruptResponse?.cancelled ?? []).flatMap((uuid) => { + result.cancelledQueuedCount = localQueuedCount + providerCancelledUuids.length; + const providerCancelledSteers = providerCancelledUuids.flatMap((uuid) => { const steer = knownQueuedMessagesAtInterrupt.get(uuid)?.steer; return steer ? [steer] : []; }); @@ -38568,21 +38556,26 @@ export function createAgentChatService(args: { * ask`), which is a separate signal from `awaitingInput`. */ const getCtoAttention = async (): Promise => { - const idle: CtoAttentionState = { awaitingInput: false, since: null }; + const idle: CtoAttentionState = { status: "idle", awaitingInput: false, since: null }; try { const cto = (await listIdentitySessions("cto"))[0]; if (!cto) return idle; const handRaisedAt = sessionService.get(cto.sessionId)?.attentionRequestedAt ?? null; const awaitingInput = Boolean(cto.awaitingInput || cto.pendingInputItemId || handRaisedAt); if (!awaitingInput) return idle; - return { awaitingInput: true, since: handRaisedAt ?? cto.lastActivityAt ?? null }; + return { + status: "awaiting-input", + awaitingInput: true, + since: handRaisedAt ?? cto.lastActivityAt ?? null, + }; } catch (error) { - // A probe failure must not break the caller; the renderer keeps its last - // known state rather than falsely clearing a pending question. + // A probe failure must not break the caller or masquerade as idle. Every + // transport forwards `unknown` so clients can retain their last known + // badge state rather than falsely clearing a pending question. logger.warn("agent_chat.cto_attention_probe_failed", { error: error instanceof Error ? error.message : String(error), }); - return idle; + return { status: "unknown", awaitingInput: false, since: null }; } }; @@ -41856,15 +41849,18 @@ export function createAgentChatService(args: { const normalizeClaudeRewindFilesResult = ( result: ClaudeRewindFilesResult, dryRun: boolean, - ): AgentChatRewindFilesResult => ({ - canRewind: result.canRewind === true, - ...(typeof result.error === "string" && result.error.trim().length ? { error: result.error.trim() } : {}), - filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged.filter((file): file is string => typeof file === "string" && file.trim().length > 0) : [], - insertions: Number.isFinite(result.insertions) ? Math.max(0, result.insertions ?? 0) : 0, - deletions: Number.isFinite(result.deletions) ? Math.max(0, result.deletions ?? 0) : 0, - ...(Number.isFinite(result.skippedLinks) ? { skippedLinks: Math.max(0, result.skippedLinks ?? 0) } : {}), - dryRun, - }); + ): AgentChatRewindFilesResult => { + const skippedLinks = normalizeClaudeRewindSkippedLinks(result); + return { + canRewind: result.canRewind === true, + ...(typeof result.error === "string" && result.error.trim().length ? { error: result.error.trim() } : {}), + filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged.filter((file): file is string => typeof file === "string" && file.trim().length > 0) : [], + insertions: Number.isFinite(result.insertions) ? Math.max(0, result.insertions ?? 0) : 0, + deletions: Number.isFinite(result.deletions) ? Math.max(0, result.deletions ?? 0) : 0, + ...(skippedLinks != null ? { skippedLinks } : {}), + dryRun, + }; + }; type CodexRewindFileRestore = { path: string; diff --git a/apps/desktop/src/main/services/chat/claudeSdkCompat.ts b/apps/desktop/src/main/services/chat/claudeSdkCompat.ts new file mode 100644 index 000000000..b5ea34f54 --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeSdkCompat.ts @@ -0,0 +1,32 @@ +export type ClaudeInterruptReceipt = { + stillQueuedUuids: string[]; + cancelledUuids: string[]; +}; + +function normalizedStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); +} + +/** Normalize fields that have moved in and out of the published Claude SDK type. */ +export function normalizeClaudeInterruptReceipt(value: unknown): ClaudeInterruptReceipt { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { stillQueuedUuids: [], cancelledUuids: [] }; + } + const record = value as Record; + return { + stillQueuedUuids: normalizedStringList(record.still_queued), + cancelledUuids: normalizedStringList(record.cancelled), + }; +} + +/** Read the newer rewind result field without coupling callers to one SDK declaration. */ +export function normalizeClaudeRewindSkippedLinks(value: unknown): number | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const skippedLinks = (value as Record).skippedLinks; + return typeof skippedLinks === "number" && Number.isFinite(skippedLinks) + ? Math.max(0, skippedLinks) + : null; +} diff --git a/apps/desktop/src/main/services/cto/ctoPromptContent.ts b/apps/desktop/src/main/services/cto/ctoPromptContent.ts index 33ead43d8..d484b8cf3 100644 --- a/apps/desktop/src/main/services/cto/ctoPromptContent.ts +++ b/apps/desktop/src/main/services/cto/ctoPromptContent.ts @@ -1,42 +1,3 @@ -import { createCtoOperatorTools, type CtoOperatorToolDeps } from "../ai/tools/ctoOperatorTools"; - -type ToolPreviewDeps = CtoOperatorToolDeps & { - previewSessionToolNames: (args: { provider?: string; model?: string; identityKey?: string }) => string[]; -}; - -const previewDeps = { - currentSessionId: "preview-cto-session", - defaultLaneId: "preview-lane", - defaultModelId: null, - defaultReasoningEffort: null, - resolveExecutionLane: async () => "preview-lane", - laneService: null, - prService: null, - fileService: null, - testService: null, - ptyService: null, - automationService: null, - gitService: null, - conflictService: null, - steerChat: undefined, - cancelSteer: undefined, - handoffChat: undefined, - listSubagents: undefined, - approveToolUse: undefined, - issueTracker: null, - ctoStateService: null, - listChats: async () => [], - getChatStatus: async () => null, - getChatTranscript: async () => null, - createChat: async () => ({ id: "preview-chat" }), - updateChatSession: async () => undefined, - sendChatMessage: async () => undefined, - interruptChat: async () => undefined, - sessionService: { updateMeta: async () => undefined }, - ensureCtoSession: async () => ({ id: "preview-cto-session", laneId: "preview-lane" }), - previewSessionToolNames: () => [], -} as unknown as ToolPreviewDeps; - /** * Onboarding step id that records the CTO's opening turn. Not a user-facing * setup step — it lives in the same list so it is persisted and so @@ -57,24 +18,11 @@ export const CTO_INTRO_PROMPT = [ "Keep it short.", ].join(" "); -function compactDescription(description: string): string { - return description - .replace(/\s+/g, " ") - .trim() - .replace(/\.$/, ""); -} - export function buildCtoCapabilityManifest(): string { - const tools = createCtoOperatorTools(previewDeps); - const lines = Object.entries(tools) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, definition]) => ` ${name} — ${compactDescription(definition.description)}`); return [ - "# ADE Operator Tools (generated reference)", - "", - "Generated from ctoOperatorTools.ts so prompt capability docs stay aligned with the registered tool surface.", + "# ADE Operator Tools", "", - ...lines, + "Use the registered ADE operator tool schemas as the authoritative capability reference. Their schemas are always loaded for CTO sessions, so their descriptions are not duplicated here.", "", "# Operating Rules", "", diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index 18cca0a62..e4d9db52f 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -275,9 +275,10 @@ describe("ctoStateService", () => { expect(preview.sections[4]?.content).toContain("Model Selection"); expect(preview.sections[4]?.content).toContain("ade actions run "); expect(preview.sections[4]?.content).toContain("bundled `ade-*` skills"); - // Capabilities section: organized tool reference with descriptions + // Capabilities section: schema authority plus cross-tool operating rules expect(preview.sections[5]?.content).toContain("ADE Operator Tools"); - expect(preview.sections[5]?.content).toContain("listLanes"); + expect(preview.sections[5]?.content).toContain("registered ADE operator tool schemas"); + expect(preview.sections[5]?.content).not.toContain("listLanes —"); expect(preview.sections[5]?.content).toContain("UI navigation is suggestion-only."); expect(preview.prompt).toContain("Immutable ADE doctrine"); expect(preview.prompt).toContain("Selected personality overlay"); @@ -310,9 +311,8 @@ describe("ctoStateService", () => { fixture.db.close(); }); - // The capability manifest is the CTO's live lane-routing lever: its operator - // tool bodies are not registered on a running session, so the prompt is what - // actually steers where CTO-launched work lands. It used to instruct + // The capability manifest keeps the cross-tool operating rules in one place. + // It used to instruct // "always default laneId to the CTO's current lane" — the CTO's lane is the // project's primary lane, so every agent it launched ran against the primary // worktree. @@ -324,11 +324,12 @@ describe("ctoStateService", () => { expect(manifest).toMatch(/primary lane/i); }); - it("generates the manifest from the registered operator tool surface", () => { + it("does not duplicate registered tool descriptions in the manifest", () => { const manifest = buildCtoCapabilityManifest(); - expect(manifest).toContain("spawnChat"); - expect(manifest).toContain("createLane"); + expect(manifest).toContain("registered ADE operator tool schemas"); + expect(manifest).not.toContain("spawnChat —"); + expect(manifest).not.toContain("createLane —"); expect(manifest).toContain("# Operating Rules"); }); }); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 45e7a4f1f..cab3c3054 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -10339,7 +10339,7 @@ export function registerIpc({ const service = getCtx().agentChatService; return service ? await service.getCtoAttention() - : { awaitingInput: false, since: null }; + : { status: "unknown", awaitingInput: false, since: null }; }); ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 9502abeda..c1c04b2b3 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -273,7 +273,7 @@ function createStubChatService() { }; }), interrupt: vi.fn().mockResolvedValue(undefined), - steer: vi.fn().mockResolvedValue(undefined), + steerUserMessage: vi.fn().mockResolvedValue(undefined), approveToolUse: vi.fn().mockResolvedValue(undefined), respondToInput: vi.fn().mockResolvedValue(undefined), resumeSession: vi.fn().mockResolvedValue(baseSession), @@ -2775,7 +2775,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { args: { sessionId: "session-1", text: "Please continue." }, }); expect((steer.result.payload as { ok: boolean }).ok).toBe(true); - expect(chatService.service.steer).toHaveBeenCalledWith({ sessionId: "session-1", text: "Please continue." }); + expect(chatService.service.steerUserMessage).toHaveBeenCalledWith({ sessionId: "session-1", text: "Please continue." }); const approve = await sendCommand(secondClient.ws, secondClient.queue, { commandId: "chat-approve", diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 3ed583f5b..b900aa184 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -207,7 +207,11 @@ function createMockAgentChatService() { summary: null, }), getChatTranscript: vi.fn().mockResolvedValue([]), - getCtoAttention: vi.fn().mockResolvedValue({ awaitingInput: true, since: "2026-01-01T00:00:00.000Z" }), + getCtoAttention: vi.fn().mockResolvedValue({ + status: "awaiting-input", + awaitingInput: true, + since: "2026-01-01T00:00:00.000Z", + }), createSession: vi.fn().mockResolvedValue({ id: "chat-1", laneId: "lane-1", @@ -2529,7 +2533,11 @@ describe("createSyncRemoteCommandService", () => { // The phone cannot derive this from its chat roster — the CTO chat is // excluded from every session list — so this is its only source. - expect(result).toEqual({ awaitingInput: true, since: "2026-01-01T00:00:00.000Z" }); + expect(result).toEqual({ + status: "awaiting-input", + awaitingInput: true, + since: "2026-01-01T00:00:00.000Z", + }); expect(service.getSupportedActions()).toContain("cto.getAttention"); }); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 77de1fa16..ed9eb69e0 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -5133,6 +5133,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), }, cto: { + getAttention: resolved({ status: "idle", awaitingInput: false, since: null }), getState: resolvedArg({ identity: ADE_DB_SNAPSHOT?.ctoState?.identity ?? { name: "CTO", diff --git a/apps/desktop/src/renderer/hooks/useCtoAttention.test.tsx b/apps/desktop/src/renderer/hooks/useCtoAttention.test.tsx new file mode 100644 index 000000000..0465885e9 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useCtoAttention.test.tsx @@ -0,0 +1,62 @@ +/* @vitest-environment jsdom */ + +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAppStore } from "../state/appStore"; +import { useCtoAttention } from "./useCtoAttention"; + +function Harness() { + useCtoAttention(); + return null; +} + +describe("useCtoAttention", () => { + const originalAde = globalThis.window.ade; + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + globalThis.window.ade = originalAde; + useAppStore.setState({ + project: null, + projectBinding: null, + showWelcome: true, + ctoAttention: { status: "idle", awaitingInput: false, since: null }, + }); + }); + + it("retains the last known badge state when the host reports unknown", async () => { + const waiting = { + status: "awaiting-input" as const, + awaitingInput: true, + since: "2026-08-01T12:00:00.000Z", + }; + const getAttention = vi.fn() + .mockResolvedValueOnce(waiting) + .mockResolvedValueOnce({ status: "unknown", awaitingInput: false, since: null }); + globalThis.window.ade = { + ...(originalAde ?? {}), + agentChat: { + ...((originalAde as { agentChat?: object })?.agentChat ?? {}), + onEvent: vi.fn(() => () => undefined), + }, + cto: { + ...((originalAde as { cto?: object })?.cto ?? {}), + getAttention, + }, + } as never; + useAppStore.setState({ + project: { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + projectBinding: null, + showWelcome: false, + ctoAttention: { status: "idle", awaitingInput: false, since: null }, + }); + + render(); + await waitFor(() => expect(useAppStore.getState().ctoAttention).toEqual(waiting)); + + window.dispatchEvent(new Event("focus")); + await waitFor(() => expect(getAttention).toHaveBeenCalledTimes(2)); + expect(useAppStore.getState().ctoAttention).toEqual(waiting); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useCtoAttention.ts b/apps/desktop/src/renderer/hooks/useCtoAttention.ts index 6a253382c..8a261737f 100644 --- a/apps/desktop/src/renderer/hooks/useCtoAttention.ts +++ b/apps/desktop/src/renderer/hooks/useCtoAttention.ts @@ -3,7 +3,7 @@ import type { CtoAttentionState } from "../../shared/types"; import { shouldRefreshSessionListForChatEvent } from "../lib/chatSessionEvents"; import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; -const IDLE: CtoAttentionState = { awaitingInput: false, since: null }; +const IDLE: CtoAttentionState = { status: "idle", awaitingInput: false, since: null }; /** * Keeps the CTO tab's "needs you" dot fresh. @@ -46,6 +46,7 @@ export function useCtoAttention(): void { try { const next = await window.ade?.cto?.getAttention?.(); if (cancelled || !next) return; + if (next.status === "unknown") return; setCtoAttention(next); } catch { // Best effort: a failed probe leaves the last known state rather than diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 90c51ae3a..2b7c08326 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -237,6 +237,7 @@ const EMPTY_TERMINAL_ATTENTION: TerminalAttentionSnapshot = { * question silently. */ const EMPTY_CTO_ATTENTION: CtoAttentionState = { + status: "idle", awaitingInput: false, since: null }; diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index f059cf183..5f7c32d43 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -86,6 +86,30 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("forwards the CTO attention probe through the paired web transport", async () => { + fake.descriptors = descriptors(["cto.getAttention"]); + fake.commandResults.set("cto.getAttention", { + status: "awaiting-input", + awaitingInput: true, + since: "2026-08-01T12:00:00.000Z", + }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await expect(adapter.ade.cto!.getAttention()).resolves.toEqual({ + status: "awaiting-input", + awaitingInput: true, + since: "2026-08-01T12:00:00.000Z", + }); + expect(fake.commandCalls).toContainEqual({ + action: "cto.getAttention", + args: {}, + opts: { projectId: "project-1" }, + }); + + adapter.dispose(); + }); + it("keeps distinct argument values in distinct stable cache keys", () => { const sparse: unknown[] = []; sparse.length = 1; diff --git a/apps/desktop/src/renderer/webclient/adapter/misc.ts b/apps/desktop/src/renderer/webclient/adapter/misc.ts index 23f3cd83a..be3e5d353 100644 --- a/apps/desktop/src/renderer/webclient/adapter/misc.ts +++ b/apps/desktop/src/renderer/webclient/adapter/misc.ts @@ -1,6 +1,7 @@ import { peerToRuntimeDeviceState, type AiConfig, + type CtoAttentionState, type GitHubStatus, type PersonalChatStreamEventsResult, type SyncDeviceRuntimeState, @@ -592,6 +593,11 @@ function createLocalPersistenceNamespaces(localState: AdapterInfra["localState"] function createCtoNamespace(call: (action: string, args: unknown, fallback: T, idempotent?: boolean) => Promise): NonNullable { return { + getAttention: () => call( + "cto.getAttention", + {}, + { status: "unknown", awaitingInput: false, since: null }, + ), getLinearProjects: () => call("cto.getLinearProjects", {}, []), getLinearQuickView: () => call("cto.getLinearQuickView", {}, null), getLinearIssuePickerData: () => call("cto.getLinearIssuePickerData", {}, null), diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index 194671a18..5cb534356 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -299,8 +299,17 @@ export type CtoSearchMemoryResult = { * attention dot — this is the one signal that keeps a hidden thread from going * silent when it asks a question. */ -export type CtoAttentionState = { - awaitingInput: boolean; - /** When the thread started waiting; null when it is not waiting. Tooltip copy. */ - since: string | null; -}; +export type CtoAttentionState = + | { status: "idle"; awaitingInput: false; since: null } + | { + status: "awaiting-input"; + awaitingInput: true; + /** When the thread started waiting; null when the exact time is unavailable. */ + since: string | null; + } + | { + /** Clients retain their last known badge state when inspection fails. */ + status: "unknown"; + awaitingInput: false; + since: null; + }; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 044ddddd8..36d4d8e13 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -285,14 +285,14 @@ struct ContentView: View { // never reach the tab-bar button VoiceOver actually focuses. Label("CTO", systemImage: "brain") .accessibilityLabel( - syncService.ctoAttention.awaitingInput ? "CTO, waiting on you" : "CTO" + syncService.ctoAttention.isAwaitingInput ? "CTO, waiting on you" : "CTO" ) } // The CTO chat is excluded from every session roster, so it cannot borrow // the Work badge above — a question from the CTO would otherwise surface // nowhere on the phone. A string badge renders as a dot-sized marker and // hides itself when nil. - .badge(syncService.ctoAttention.awaitingInput ? "!" : nil) + .badge(syncService.ctoAttention.isAwaitingInput ? "!" : nil) } } diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index fd3c49baa..1bdfb9eb0 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1239,10 +1239,33 @@ struct CtoSnapshot: Codable, Hashable { /// deliberately excluded from every session list — so it has to ask. `since` is /// optional because the host sends JSON `null` when nothing is waiting. struct CtoAttention: Codable, Hashable { + enum Status: String, Codable { + case idle + case awaitingInput = "awaiting-input" + case unknown + } + + /// Optional for compatibility with hosts released before the explicit + /// unknown state. A missing status is inferred from `awaitingInput`. + var status: Status? var awaitingInput: Bool var since: String? - static let idle = CtoAttention(awaitingInput: false, since: nil) + var effectiveStatus: Status { + status ?? (awaitingInput ? .awaitingInput : .idle) + } + + var isAwaitingInput: Bool { + effectiveStatus == .awaitingInput + } + + /// Applies a successful host probe without letting an indeterminate result + /// erase the last known badge state. + func updating(with probe: CtoAttention) -> CtoAttention { + probe.effectiveStatus == .unknown ? self : probe + } + + static let idle = CtoAttention(status: .idle, awaitingInput: false, since: nil) } /// Returned by the `cto.getMemory` sync command: the durable facts the CTO diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 26f2cbe5d..4cfb80762 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -19290,7 +19290,7 @@ extension SyncService { guard supportsRemoteAction("cto.getAttention") else { // Older brain: never light the dot, and clear a value left over from a // newer host we were previously paired with. - if ctoAttention.awaitingInput { ctoAttention = .idle } + if ctoAttention.isAwaitingInput { ctoAttention = .idle } return } guard canSendLiveRequests() else { return } @@ -19302,13 +19302,10 @@ extension SyncService { defer { self.ctoAttentionTask = nil } do { let next = try await self.fetchCtoAttention() - self.ctoAttention = next + self.ctoAttention = self.ctoAttention.updating(with: next) } catch { - // Keep the last known state on a TRANSPORT failure — dropping a pending - // question is worse than a stale dot. Note this does not cover a - // host-side probe failure: `getCtoAttention` swallows those into - // `idle`, so the badge would clear. Distinguishing them needs an - // explicit "unknown" in CtoAttentionState across all three transports. + // Keep the last known state on a transport failure. Host-side probe + // failures arrive as explicit `unknown` and are ignored above. } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 95398ad18..9e62b5342 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16898,23 +16898,29 @@ final class ADETests: XCTestCase { ) } - func testCtoAttentionDecodesHostIdleAndWaitingPayloads() throws { - // `cto.getAttention` returns `{ awaitingInput, since }` and the host sends + func testCtoAttentionDecodesLegacyAndExplicitStatesAndRetainsUnknownProbe() throws { + // `cto.getAttention` returns `{ status, awaitingInput, since }` and the host sends // JSON `null` for `since` whenever nothing is waiting — a non-optional // `since` would throw there and the tab badge would silently never light. let waitingData = try JSONSerialization.data(withJSONObject: [ + "status": "awaiting-input", "awaitingInput": true, "since": "2026-07-31T00:00:00Z", ]) let waiting = try JSONDecoder().decode(CtoAttention.self, from: waitingData) + XCTAssertEqual(waiting.effectiveStatus, .awaitingInput) + XCTAssertTrue(waiting.isAwaitingInput) XCTAssertTrue(waiting.awaitingInput) XCTAssertEqual(waiting.since, "2026-07-31T00:00:00Z") let idleData = try JSONSerialization.data(withJSONObject: [ + "status": "idle", "awaitingInput": false, "since": NSNull(), ]) let idle = try JSONDecoder().decode(CtoAttention.self, from: idleData) + XCTAssertEqual(idle.effectiveStatus, .idle) + XCTAssertFalse(idle.isAwaitingInput) XCTAssertFalse(idle.awaitingInput) XCTAssertNil(idle.since) XCTAssertEqual(idle, CtoAttention.idle) @@ -16922,8 +16928,23 @@ final class ADETests: XCTestCase { // An older/leaner host may omit the key entirely rather than send null. let omittedData = try JSONSerialization.data(withJSONObject: ["awaitingInput": true]) let omitted = try JSONDecoder().decode(CtoAttention.self, from: omittedData) + XCTAssertEqual(omitted.effectiveStatus, .awaitingInput) + XCTAssertTrue(omitted.isAwaitingInput) XCTAssertTrue(omitted.awaitingInput) XCTAssertNil(omitted.since) + + let unknownData = try JSONSerialization.data(withJSONObject: [ + "status": "unknown", + "awaitingInput": false, + "since": NSNull(), + ]) + let unknown = try JSONDecoder().decode(CtoAttention.self, from: unknownData) + XCTAssertEqual(unknown.effectiveStatus, .unknown) + XCTAssertFalse(unknown.isAwaitingInput) + + XCTAssertEqual(waiting.updating(with: unknown), waiting) + XCTAssertEqual(waiting.updating(with: idle), idle) + XCTAssertEqual(idle.updating(with: waiting), waiting) } func testCtoOnboardingDismissedOnDesktopDoesNotBlockIosTab() { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b66101c2b..b9bbab200 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1077,9 +1077,12 @@ rows. `useCtoAttention` reads it separately through the read-only draws the dot on `/cto`, and `useAppWideSessionAttention` folds that one flag into its badge count so it remains the single writer of `setDockBadgeCount`. iOS reaches the same `agentChatService.getCtoAttention()` implementation through the -optional `cto.getAttention` sync command and badges its CTO tab. The probe must -stay side-effect-free on every transport — creating the CTO session to draw a -badge would materialize a primary lane. See +optional `cto.getAttention` sync command and badges its CTO tab; the hosted web +adapter forwards the same command into the renderer hook. The probe returns +`idle`, `awaiting-input`, or `unknown`, and clients retain their last known badge +on `unknown` so a transient host scan failure cannot falsely clear it. The probe +must stay side-effect-free on every transport — creating the CTO session to draw +a badge would materialize a primary lane. See [features/cto/README.md](./features/cto/README.md#hidden-from-rosters-but-never-silent). ### 8.3 ADE CLI auth + API-key storage diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 4142a7537..e73297357 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -39,6 +39,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/shared/types/personalChats.ts` | Machine-scope personal-chat action, capability, result, queue-policy, and event-stream contract layered over the same `AgentChatSession` DTOs. | | `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` | Builds Claude SDK user messages for the `query()` input stream. Handles base64 image content blocks and MIME inference. | | `apps/desktop/src/main/services/chat/claudeInputPump.ts` | Async iterable input pump that feeds live user turns into the Claude Agent SDK `query()` stream. | +| `apps/desktop/src/main/services/chat/claudeSdkCompat.ts` | Narrow runtime normalizers for Claude SDK response fields whose published declarations have drifted across SDK releases. It defensively reads interrupt receipt UUIDs (`still_queued`, `cancelled`) and rewind `skippedLinks` without casting the whole chat service to an inaccurate SDK shape. | | `apps/desktop/src/main/services/chat/claudeThinkingTranscriptRepair.ts` | Best-effort repair for Claude SDK JSONL transcripts where multiple distinct assistant responses reused one `message.id`. The repair preserves top-level threading, tool ids, thinking content, and signatures, but rekeys later responses before resume so Anthropic thinking blocks remain in the message shape originally generated by the model. | | `apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts` | Resume-time repair for historical ADE envelope streams written before Claude text fragments used the stable SDK message id. It detects only runs of at least three consecutive text envelopes in one turn with distinct message ids, rebuilds from SDK message text when possible (otherwise locally merges), preserves every other JSONL line verbatim, skips files over 64 MB, and rewrites atomically with a one-time `.splice.bak`. | | `apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts` | Tracks Claude SDK subprocesses and tears them down on runtime shutdown. | diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index 5fbb6a15d..d26e0827b 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -10,7 +10,7 @@ The whole surface is built around one contract: the CTO is a daily chat you can - `ctoStateService.ts` — identity (name, personality, work style, model preferences), session logs, onboarding state, and the system-prompt preview. Owns the immutable doctrine, personality overlays, continuity model, memory-system guidance, environment knowledge, and capability manifest constants. `buildReconstructionContext()` assembles the memory-enriched context injected on session start, compaction, and model switch; `previewSystemPrompt()` returns the same layered prompt the settings UI renders verbatim. - `ctoMemoryService.ts` — the smart-memory file store under `.ade/cto/`. Reads/writes `MEMORY.md` and `thread-state.md` (atomic writes), appends per-turn lines to `daily/.md`, exposes `searchMemory(query)` (bounded, file-based, most-recent-first), `getSnapshot()`, and `buildMemoryContextSections()` (the capped copies used for injection). No new database or vector dependency. -- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool manifest injected into the prompt. It is generated directly from `createCtoOperatorTools()` so registered tools and prompt documentation stay aligned; its operating rules are what keep CTO-launched work off the primary lane. Also owns `CTO_INTRO_PROMPT` and `CTO_INTRO_ONBOARDING_STEP` — the opening turn and the once-only marker described in [The opening turn](#the-opening-turn). +- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool operating rules injected into the prompt. Registered tool schemas are the authoritative capability reference; the prompt does not repeat their descriptions. The retained operating rules are what keep CTO-launched work off the primary lane. Also owns `CTO_INTRO_PROMPT` and `CTO_INTRO_ONBOARDING_STEP` — the opening turn and the once-only marker described in [The opening turn](#the-opening-turn). - `linearClient.ts` — Linear GraphQL client (shared by desktop and the headless ADE CLI). Reads: `fetchIssueById`, `listProjects`, `searchIssues`, `getQuickView`, `fetchIssueComments`, `listLabels`, `listUsers`. Writes: `updateIssueState`, `updateIssueAssignee`, `createComment`, `addIssueLabel` / `removeIssueLabel`. - `linearIssueTracker.ts` / `issueTracker.ts` — issue cache, change detection, and the `getQuickView` / `searchIssues` / `fetchIssueComments` read shims plus the `updateIssueState` / `updateIssueAssignee` / `createComment` / `addLabel` write surface renderer surfaces call through. - `linearGraphQLInput.ts` — GraphQL input builders shared by the client and tracker. @@ -36,9 +36,9 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `apps/desktop/src/shared/ctoPersonalityPresets.ts` — `CTO_PERSONALITY_PRESETS` (`strategic`, `professional`, `hands_on`, `casual`, `minimal`, `custom`) with label, description, and `systemOverlay`. - `apps/desktop/src/shared/types/chat.ts` — `AgentChatIdentityKey`, now just the literal `"cto"`. The old `agent:` worker identity keys are gone. -- `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` — the operator tool surface. `createCtoOperatorTools()` is the single factory behind both the prompt manifest and the tools a running CTO session can actually call (see [Operator tools on a live session](#operator-tools-on-a-live-session)). It includes the memory tools `saveMemory`, `searchMemory`, and `readMemory`, the session-lifecycle tools described in [Session lifecycle tools](#session-lifecycle-tools), and the git tools whose mutating half refuses to default a lane (`resolveReadLaneId` vs `requireMutationLaneId`). +- `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` — the operator tool surface. `createCtoOperatorTools()` is the single factory behind the tools a running CTO session can actually call (see [Operator tools on a live session](#operator-tools-on-a-live-session)). It includes the memory tools `saveMemory`, `searchMemory`, and `readMemory`, the session-lifecycle tools described in [Session lifecycle tools](#session-lifecycle-tools), and the git tools whose mutating half refuses to default a lane (`resolveReadLaneId` vs `requireMutationLaneId`). - `apps/desktop/src/main/services/chat/agentChatService.ts` — owns the CTO session lifecycle: single-session reuse/rebind (`listIdentitySessions` / `ensureIdentitySession`), the memory flush hooks, the reconstruction-context injection, `seedCtoIntroTurn` (the opening turn), `resolveCtoExecutionLane` (where CTO-launched work runs), `buildCtoOperatorToolDeps` / `createCtoRuntimeToolMap` plus the per-provider transports that register them, and the canonical `getCtoAttention` probe (all detailed below). -- `apps/desktop/src/shared/types/cto.ts` — `CtoAttentionState` (`{ awaitingInput, since }`), the shape every attention transport returns. +- `apps/desktop/src/shared/types/cto.ts` — the discriminated `CtoAttentionState` (`idle`, `awaiting-input`, or `unknown`), the shape every attention transport returns. `unknown` means inspection failed and clients must retain their last known badge state. ### Attention surfaces (renderer) @@ -46,6 +46,7 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `apps/desktop/src/renderer/state/appStore.ts` — `ctoAttention` + `setCtoAttention`, reset to idle on every project switch/close alongside `terminalAttention`. - `apps/desktop/src/renderer/components/app/TabNav.tsx` — renders the warning dot on the `/cto` tab with a "waiting since" tooltip. - `apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts` — folds `ctoAttention.awaitingInput` into the dock badge count while remaining the only writer of `setDockBadgeCount`. +- `apps/desktop/src/renderer/webclient/adapter/misc.ts` — forwards `getAttention` through the paired runtime's `cto.getAttention` command, so the hosted web `/cto` tab uses the same probe and retention semantics as Electron. ### iOS companion (`apps/ios/ADE/Views/Cto/`) @@ -54,7 +55,7 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `CtoSetup.swift` — the first-run card (name, personality preset, work-style rows) shown when onboarding is incomplete. - `CtoSettingsScreen.swift` — sections: Identity (including personality/work style via `CtoIdentityEditor`), Model (live model/reasoning/Fast selection), Integrations (read-only Linear connection status), Memory (durable facts + thread summary via `cto.getMemory`), and Advanced (re-run setup). - `CtoIdentityEditor.swift` / `CtoReloadHelpers.swift` — the identity edit sheet and reload plumbing. -- `apps/ios/ADE/Models/RemoteModels.swift` — `CtoAttention` (`awaitingInput` + optional `since`, plus an `idle` constant), the Codable mirror of `CtoAttentionState`. +- `apps/ios/ADE/Models/RemoteModels.swift` — `CtoAttention` (`status`, `awaitingInput`, optional `since`, plus effective-status compatibility for older hosts), the Codable mirror of `CtoAttentionState`. - `apps/ios/ADE/Services/SyncService.swift` — `fetchCtoAttention()` (the `cto.getAttention` call), the `@Published ctoAttention`, and `refreshCtoAttentionIfNeeded()`, called from `refreshActiveSessionsAndSnapshot()` above its roster-signature early return and from `saveRemoteCommandDescriptors` with `force: true`. The CTO tab icon is the SF Symbol `brain` (`apps/ios/ADE/App/ContentView.swift`), matching the desktop Phosphor Brain glyph; the same tab carries the attention badge described in [Hidden from rosters, but never silent](#hidden-from-rosters-but-never-silent). @@ -70,7 +71,7 @@ The system prompt is assembled from layered sections (`ctoStateService.previewSy 3. **Continuity model** (`CTO_CONTINUITY_OPERATING_MODEL`) — how ADE re-grounds the CTO across compaction and resumes. 4. **Persistent memory guidance** (`CTO_MEMORY_SYSTEM_GUIDANCE`) — teaches the CTO that it has durable, model-agnostic memory and how to use the `saveMemory` / `searchMemory` / `readMemory` tools proactively. 5. **Environment knowledge** — a glossary of ADE entities (lanes, chats vs terminals, PRs, conflicts, automations, Linear reads) plus intent-to-tool routing, including the live model registry. -6. **Capability manifest** — the full operator tool surface, injected verbatim so the CTO can pick the right tool. +6. **Capability rules** — cross-tool operating rules that are not expressed by any one schema. The registered tool schemas already describe the full tool surface. ### Identity record and work style @@ -119,11 +120,11 @@ The CTO thread is pinned to the project's **primary lane** (it needs a lane for Hiding the row removes it from `terminalAttention`, which is what the Work dot and the dock badge summarize. A hidden thread that asks a question would otherwise surface nowhere, so attention gets its own path: -- `agentChatService.getCtoAttention()` is the single implementation. All three transports — `IPC.ctoGetAttention` (plain IPC), the `cto_state.getAttention` action (daemon-routed), and the `cto.getAttention` sync command (mobile) — delegate to it, so a remote runtime, a local one, and a phone cannot derive "needs you" differently. It returns `CtoAttentionState`, just `{ awaitingInput, since }`; `since` is the tooltip timestamp and is `null` while idle. -- It is **read-only**. It resolves the thread through the same `listIdentitySessions` helper `ensureIdentitySession` uses, but never calls `ensureIdentitySession` itself: rendering a badge must not materialize a primary lane and a chat session as a side effect. The predicate is `awaitingInput || pendingInputItemId || attentionRequestedAt` (the last being an explicit `ade chat ask` hand-raise) rather than `canonicalStatusBucket`, whose awaiting-input bucket folds in `idle` and `ready` and would light the dot whenever the CTO is merely sitting there. A probe failure logs and returns idle. -- `useCtoAttention` (mounted once in `AppShell`) keeps `appStore.ctoAttention` fresh from chat events, focus, and a 15 s visible-tab interval; `TabNav` renders the dot on `/cto`. It filters chat events through `shouldRefreshSessionListForChatEvent` so a streaming turn does not re-run a full identity scan per delta, debounces to 1.5 s (0 on focus), and clears to idle on project switch so the previous project's state cannot linger. +- `agentChatService.getCtoAttention()` is the single implementation. All three transports — `IPC.ctoGetAttention` (plain IPC), the `cto_state.getAttention` action (daemon-routed), and the `cto.getAttention` sync command (mobile and hosted web) — delegate to it, so a remote runtime, local Electron window, browser client, and phone cannot derive "needs you" differently. It returns a discriminated `CtoAttentionState`: `idle`, `awaiting-input` with an optional tooltip timestamp, or `unknown` when inspection failed. +- It is **read-only**. It resolves the thread through the same `listIdentitySessions` helper `ensureIdentitySession` uses, but never calls `ensureIdentitySession` itself: rendering a badge must not materialize a primary lane and a chat session as a side effect. The predicate is `awaitingInput || pendingInputItemId || attentionRequestedAt` (the last being an explicit `ade chat ask` hand-raise) rather than `canonicalStatusBucket`, whose awaiting-input bucket folds in `idle` and `ready` and would light the dot whenever the CTO is merely sitting there. A probe failure logs and returns `unknown`, never a false `idle`. +- `useCtoAttention` (mounted once in `AppShell`) keeps `appStore.ctoAttention` fresh from chat events, focus, and a 15 s visible-tab interval; `TabNav` renders the dot on `/cto`. It filters chat events through `shouldRefreshSessionListForChatEvent` so a streaming turn does not re-run a full identity scan per delta, debounces to 1.5 s (0 on focus), ignores `unknown` so the last known state survives a failed host scan, and clears to idle on project switch so the previous project's state cannot linger. The hosted web adapter now exposes `getAttention` over `cto.getAttention`, so this same renderer hook works in paired-browser mode. - `useAppWideSessionAttention` adds the CTO to the dock badge count so a question reaches a minimized window. It stays the single writer of `setDockBadgeCount`. -- **iOS** takes the same path. `SyncService.fetchCtoAttention()` calls `cto.getAttention` and publishes `ctoAttention`; `ContentView` badges the CTO tab (a string badge, so it renders as a dot-sized marker and disappears when idle) with a matching accessibility label. `refreshCtoAttentionIfNeeded()` rides the same "something changed" pulse that rebuilds the session roster — the CTO is not *in* that roster, so it needs its own read. It is called from `refreshActiveSessionsAndSnapshot()` **above** the roster-signature early return, not below it: since the CTO is excluded from `allAgents`, a turn where only the CTO changed leaves the signature identical, so a probe hanging below the guard would fire only when some unrelated session happened to change — and, once lit, would never clear. It is also called with `force: true` from `saveRemoteCommandDescriptors`, because the probe no-ops until it knows the host advertises the command, so the first read after a (re)connect has to happen when the descriptors land and must skip the debounce a reconnect could land inside. Otherwise it is debounced to 5 s. It is gated on `supportsRemoteAction("cto.getAttention")`: an older brain never lights the dot, and a value left over from a newer host is cleared. A failed probe keeps the last known value rather than clearing, because falsely dropping a pending question is worse than a slightly stale dot. +- **iOS** takes the same path. `SyncService.fetchCtoAttention()` calls `cto.getAttention` and publishes `ctoAttention`; `ContentView` badges the CTO tab (a string badge, so it renders as a dot-sized marker and disappears when idle) with a matching accessibility label. `refreshCtoAttentionIfNeeded()` rides the same "something changed" pulse that rebuilds the session roster — the CTO is not *in* that roster, so it needs its own read. It is called from `refreshActiveSessionsAndSnapshot()` **above** the roster-signature early return, not below it: since the CTO is excluded from `allAgents`, a turn where only the CTO changed leaves the signature identical, so a probe hanging below the guard would fire only when some unrelated session happened to change — and, once lit, would never clear. It is also called with `force: true` from `saveRemoteCommandDescriptors`, because the probe no-ops until it knows the host advertises the command, so the first read after a (re)connect has to happen when the descriptors land and must skip the debounce a reconnect could land inside. Otherwise it is debounced to 5 s. It is gated on `supportsRemoteAction("cto.getAttention")`: an older brain never lights the dot, and a value left over from a newer host is cleared. A transport error or explicit `unknown` result keeps the last known value rather than clearing, because falsely dropping a pending question is worse than a slightly stale dot. The wire `status` remains optional when decoding so iOS infers `idle`/`awaiting-input` from `awaitingInput` against older hosts. ### The opening turn @@ -139,11 +140,13 @@ advertised in its prompt. `createCtoRuntimeToolMap(managed)` in whose `identityKey` is not `"cto"`, so no other chat can reach these tools. `buildCtoOperatorToolDeps` builds the dependency set for both the runtime map -and `previewSessionToolNames`, which enumerates the same tool names for the -prompt. Sharing the deps is the point: the surface the CTO is told it has and -the surface it can actually call come from one definition and cannot drift -apart. (`buildCtoCapabilityManifest` renders its inventory from the same -`createCtoOperatorTools()` factory, with stub deps, for the same reason.) +and `previewSessionToolNames`. Registered schemas are always loaded for CTO +sessions and are the authoritative capability reference. A live measurement +found that repeating the generated inventory in the prompt added about 11.8k +characters (roughly 2,945 estimated tokens) on top of about 28.8k tokens of MCP +tool schemas, so `buildCtoCapabilityManifest` now carries only cross-tool +operating rules. Tool search remains disabled and `alwaysLoad` remains enabled; +removing the duplicate prose reduces context without making tools undiscoverable. Every chat-control dep — `steerChat`, `cancelSteer`, `listSubagents`, `approveToolUse` — is **required** on `CtoOperatorToolDeps` and wired to the diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f45e5ca52..d48ad7760 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1846,7 +1846,7 @@ any non-primary-key unique index. | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `SearchOverlay` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. Each session row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside the lane name. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: GitHub stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, Integration/Rebase workflow cards rendered from `PrWorkflowCard`, and per-PR action capabilities. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | -| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`) and publishes `ctoAttention`. It rides the change pulse that rebuilds the session roster, but is invoked *before* `refreshActiveSessionsAndSnapshot`'s roster-signature early return — the CTO is excluded from that roster, so a CTO-only change leaves the signature unchanged and a probe below the guard could never fire. `saveRemoteCommandDescriptors` also calls it with `force: true`, so the first probe after a (re)connect happens as soon as the host advertises the command. A failed probe keeps the last known value; an older brain that does not advertise the action clears it. | +| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`) and publishes `ctoAttention`. It rides the change pulse that rebuilds the session roster, but is invoked *before* `refreshActiveSessionsAndSnapshot`'s roster-signature early return — the CTO is excluded from that roster, so a CTO-only change leaves the signature unchanged and a probe below the guard could never fire. `saveRemoteCommandDescriptors` also calls it with `force: true`, so the first probe after a (re)connect happens as soon as the host advertises the command. Transport failures and the host's explicit `unknown` status both keep the last known value; an older brain that does not advertise the action clears it. The decoded status is optional so a new phone still infers idle/waiting correctly from the legacy `awaitingInput` field. | | **Settings** | `gearshape` | `/settings` (sync subset) | Connections — account sign-in (primary, PIN-less directory + Relay adoption), account-wide machine rename/clear, scan the QR (`SettingsPairingScannerSheet`) + PIN, or Nearby + PIN — plus advanced SSH bootstrap, appearance, diagnostics, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. The About row formats the marketing and build versions together as `v ()`. | `WorkModelPickerSheet` shows the same Claude authentication affordance diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 7f580a00d..a1f7d0503 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -503,8 +503,10 @@ a boolean. drawing a badge cannot materialize a primary lane and a CTO chat session as a side effect. The phone needs its own command here because the CTO chat is excluded from every session roster and cannot be derived from the - chat list. It is an **optional** mobile capability (see the compatibility - note above). + chat list. The result is `idle`, `awaiting-input`, or `unknown`; mobile and + hosted-web clients retain their last known badge state on `unknown` instead + of treating a failed host scan as idle. It is an **optional** mobile + capability (see the compatibility note above). - `getLinearConnectionStatus`, `getLinearQuickView`, `getLinearIssuePickerData`, `searchLinearIssues`, `getLinearIssueComments` — the Linear read surface. The former worker-management commands From 2d4ae9dfd35165023ca112dfc49af11a8c2932f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:48:59 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Refs=20ADE-136:=20ship:=20iteration=201=20?= =?UTF-8?q?=E2=80=94=20address=20#3696709345?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/services/cto/ctoPromptContent.ts | 2 +- apps/desktop/src/main/services/cto/ctoState.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/cto/ctoPromptContent.ts b/apps/desktop/src/main/services/cto/ctoPromptContent.ts index d484b8cf3..eff12172d 100644 --- a/apps/desktop/src/main/services/cto/ctoPromptContent.ts +++ b/apps/desktop/src/main/services/cto/ctoPromptContent.ts @@ -20,7 +20,7 @@ export const CTO_INTRO_PROMPT = [ export function buildCtoCapabilityManifest(): string { return [ - "# ADE Operator Tools", + "# ADE operator tools", "", "Use the registered ADE operator tool schemas as the authoritative capability reference. Their schemas are always loaded for CTO sessions, so their descriptions are not duplicated here.", "", diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index e4d9db52f..6174b28d0 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -276,7 +276,7 @@ describe("ctoStateService", () => { expect(preview.sections[4]?.content).toContain("ade actions run "); expect(preview.sections[4]?.content).toContain("bundled `ade-*` skills"); // Capabilities section: schema authority plus cross-tool operating rules - expect(preview.sections[5]?.content).toContain("ADE Operator Tools"); + expect(preview.sections[5]?.content).toContain("ADE operator tools"); expect(preview.sections[5]?.content).toContain("registered ADE operator tool schemas"); expect(preview.sections[5]?.content).not.toContain("listLanes —"); expect(preview.sections[5]?.content).toContain("UI navigation is suggestion-only."); From bf339a2bc0e05e86ec985648c748f113d8af9c2b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:22:55 -0400 Subject: [PATCH 3/3] =?UTF-8?q?Refs=20ADE-136:=20ship:=20iteration=202=20?= =?UTF-8?q?=E2=80=94=20address=20#3696743285?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ios/ADE/Services/SyncService.swift | 22 +++++++++++++++++++++- apps/ios/ADETests/ADETests.swift | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4cfb80762..910f432a7 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3852,6 +3852,7 @@ final class SyncService: ObservableObject { @Published private(set) var ctoAttention: CtoAttention = .idle private var ctoAttentionTask: Task? + private var ctoAttentionGeneration: UInt64 = 0 private var lastCtoAttentionFetchAt: Date? /// 2s debounce task shared by all writers of the App Group workspace @@ -4824,6 +4825,7 @@ final class SyncService: ObservableObject { } let scopeChanged = previousProjectId != nextProjectId || previousRootPath != nextRootPath if scopeChanged { + resetCtoAttentionForProjectScopeChange() cancelAllTerminalSnapshotRecovery() terminalSnapshotRequestTokens.removeAll() prepareOutboundStateForProjectScopeChange() @@ -4921,6 +4923,14 @@ final class SyncService: ObservableObject { } } + private func resetCtoAttentionForProjectScopeChange() { + ctoAttentionGeneration &+= 1 + ctoAttentionTask?.cancel() + ctoAttentionTask = nil + lastCtoAttentionFetchAt = nil + ctoAttention = .idle + } + private func normalizedProjectRoot(_ rootPath: String?) -> String? { syncNormalizedProjectRootScope(rootPath) } @@ -15650,6 +15660,10 @@ final class SyncService: ObservableObject { resetOutboundCursorStateForActiveProject() } + func setCtoAttentionForTesting(_ attention: CtoAttention) { + ctoAttention = attention + } + func ensureActiveProjectCacheRowForTesting() throws { try ensureActiveProjectCacheRowForHydration() } @@ -19297,11 +19311,17 @@ extension SyncService { guard ctoAttentionTask == nil else { return } if !force, let last = lastCtoAttentionFetchAt, Date().timeIntervalSince(last) < 5 { return } lastCtoAttentionFetchAt = Date() + let generation = ctoAttentionGeneration ctoAttentionTask = Task { [weak self] in guard let self else { return } - defer { self.ctoAttentionTask = nil } + defer { + if self.ctoAttentionGeneration == generation { + self.ctoAttentionTask = nil + } + } do { let next = try await self.fetchCtoAttention() + guard self.ctoAttentionGeneration == generation else { return } self.ctoAttention = self.ctoAttention.updating(with: next) } catch { // Keep the last known state on a transport failure. Host-side probe diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9e62b5342..fc3366852 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16947,6 +16947,21 @@ final class ADETests: XCTestCase { XCTAssertEqual(idle.updating(with: waiting), waiting) } + @MainActor + func testCtoAttentionResetsWhenActiveProjectChanges() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.setActiveProjectForTesting(projectId: "project-a", rootPath: "/tmp/project-a") + service.setCtoAttentionForTesting(CtoAttention( + status: .awaitingInput, + awaitingInput: true, + since: "2026-07-31T00:00:00Z" + )) + + service.setActiveProjectForTesting(projectId: "project-b", rootPath: "/tmp/project-b") + + XCTAssertEqual(service.ctoAttention, .idle) + } + func testCtoOnboardingDismissedOnDesktopDoesNotBlockIosTab() { func identity(_ state: CtoOnboardingState?) -> CtoIdentity { CtoIdentity(