diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 503dcd069..c35a65b51 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -420,7 +420,7 @@ describe("headlessLinearServices", () => { }); expect(session.title).toBe("CTO Headless Session"); expect(session.model).toBe("gpt-5.5"); - expect(session.modelId).toBe("openai/gpt-5.5-codex"); + expect(session.modelId).toBe("openai/gpt-5.5"); services.dispose(); }); @@ -440,7 +440,7 @@ describe("headlessLinearServices", () => { }); expect(codex.model).toBe("gpt-5.5"); - expect(codex.modelId).toBe("openai/gpt-5.5-codex"); + expect(codex.modelId).toBe("openai/gpt-5.5"); expect(claude.model).toBe("opus-1m"); expect(claude.modelId).toBe("anthropic/claude-opus-4-7-1m"); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index e28098233..9bfd53440 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -396,7 +396,7 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ const identitySessionIds = new Map(); const transcripts = new Map(); - const HEADLESS_MODEL_ID = "openai/gpt-5.5-codex"; + const HEADLESS_MODEL_ID = "openai/gpt-5.5"; const clipText = (value: string, maxChars: number): string => { const trimmed = value.trim(); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 328e9ed31..5f0772688 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -296,14 +296,14 @@ describe("aiIntegrationService", () => { taskType: "review", prompt: "Evaluate this step", cwd: "/tmp", - model: "openai/gpt-5.4-codex", + model: "openai/gpt-5.4", sessionId: "carry-forward-session", permissionMode: "read-only", }); expect(mockState.runProviderTask).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "carry-forward-session", - descriptor: expect.objectContaining({ id: "openai/gpt-5.4-codex" }), + descriptor: expect.objectContaining({ id: "openai/gpt-5.4" }), permissionMode: "read-only", })); }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 50faa638d..9c827e629 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -192,7 +192,7 @@ const DEFAULT_AI_FEATURE_FLAGS: Record = { }; const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; +const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; const TASK_DEFAULTS: Record = { planning: { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index f8b41f907..08d42df6e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -763,7 +763,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { triggers: [{ type: "manual" as const }], executor: { mode: "automation-bot", targetId: null }, modelConfig: { - orchestratorModel: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "low" }, + orchestratorModel: { modelId: "openai/gpt-5.4", thinkingLevel: "low" }, }, permissionConfig: { providers: { codex: "default" as const, opencode: "edit" as const } }, toolPalette: [] as const, @@ -1242,7 +1242,7 @@ describe("automationService integration", () => { }, modelConfig: { orchestratorModel: { - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", thinkingLevel: "medium", }, }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f7b8ac420..affc3bce1 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5456,7 +5456,7 @@ describe("createAgentChatService", () => { | { mode?: unknown; settings?: { model?: unknown; reasoning_effort?: unknown; developer_instructions?: unknown } } | undefined; - expect(params?.approvalPolicy).toBe("untrusted"); + expect(params?.approvalPolicy).toBe("unlessTrusted"); expect(params?.sandboxPolicy?.type).toBe("readOnly"); expect(params?.effort).toBe("medium"); expect(collaborationMode?.mode).toBe("plan"); @@ -5509,7 +5509,7 @@ describe("createAgentChatService", () => { } | undefined; const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined; - expect(params?.approvalPolicy).toBe("on-request"); + expect(params?.approvalPolicy).toBe("onRequest"); expect(params?.sandboxPolicy?.type).toBe("workspaceWrite"); expect(params?.effort).toBe("medium"); expect(collaborationMode?.mode).toBe("default"); @@ -5573,8 +5573,8 @@ describe("createAgentChatService", () => { reasoningEffort?: unknown; } | undefined; expect(params?.approvalPolicy).toBe("never"); - expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.reasoningEffort).toBeUndefined(); + expect(params?.sandbox).toBe("dangerFullAccess"); + expect(params?.reasoningEffort).toBe("medium"); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { @@ -5587,10 +5587,10 @@ describe("createAgentChatService", () => { expect(turnStartParams?.effort).toBe("medium"); }); - it("uses the app-server's effective Codex policy for subsequent turn/start overrides", async () => { + it("persists the runtime-confirmed Codex reasoning effort while applying effective thread policy", async () => { mockState.codexResponseOverrides.set("thread/start", () => ({ thread: { id: "thread-effective-start" }, - approvalPolicy: "on-failure", + approvalPolicy: "onFailure", sandbox: { type: "workspaceWrite", writableRoots: [], @@ -5607,6 +5607,7 @@ describe("createAgentChatService", () => { laneId: "lane-1", provider: "codex", model: "gpt-5.4", + reasoningEffort: "xhigh", }); await service.sendMessage({ @@ -5618,13 +5619,15 @@ describe("createAgentChatService", () => { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); }); + const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect((threadStartRequest?.params as { reasoningEffort?: unknown } | undefined)?.reasoningEffort).toBe("xhigh"); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { approvalPolicy?: unknown; sandboxPolicy?: { type?: unknown }; effort?: unknown; } | undefined; - expect(turnStartParams?.approvalPolicy).toBe("on-failure"); + expect(turnStartParams?.approvalPolicy).toBe("onFailure"); expect(turnStartParams?.sandboxPolicy?.type).toBe("workspaceWrite"); expect(turnStartParams?.effort).toBe("high"); @@ -5706,8 +5709,8 @@ describe("createAgentChatService", () => { reasoningEffort?: unknown; } | undefined; expect(params?.approvalPolicy).toBe("never"); - expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.reasoningEffort).toBeUndefined(); + expect(params?.sandbox).toBe("dangerFullAccess"); + expect(params?.reasoningEffort).toBe("medium"); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { @@ -5785,7 +5788,7 @@ describe("createAgentChatService", () => { const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume"); const resumeParams = resumeRequest?.params as { approvalPolicy?: unknown; sandbox?: unknown } | undefined; expect(resumeParams?.approvalPolicy).toBe("never"); - expect(resumeParams?.sandbox).toBe("danger-full-access"); + expect(resumeParams?.sandbox).toBe("dangerFullAccess"); }); it("does not auto-upgrade default Codex chats into plan mode", async () => { @@ -5862,10 +5865,10 @@ describe("createAgentChatService", () => { expect(sessionService.reopen).toHaveBeenCalledWith(session.id); }); - it("trusts the app-server's effective Codex policy on resume", async () => { + it("persists runtime-confirmed Codex reasoning effort while applying effective policy on resume", async () => { mockState.codexResponseOverrides.set("thread/resume", () => ({ thread: { id: "thread-effective-resume" }, - approvalPolicy: "on-failure", + approvalPolicy: "onFailure", sandbox: { type: "workspaceWrite" }, reasoningEffort: "high", })); @@ -5886,11 +5889,13 @@ describe("createAgentChatService", () => { codexApprovalPolicy: "never", codexSandbox: "danger-full-access", codexConfigSource: "flags", - reasoningEffort: "medium", + reasoningEffort: "xhigh", }); const resumed = await service.resumeSession({ sessionId: session.id }); + const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume"); + expect((resumeRequest?.params as { reasoningEffort?: unknown } | undefined)?.reasoningEffort).toBe("xhigh"); expect(resumed.codexApprovalPolicy).toBe("on-failure"); expect(resumed.codexSandbox).toBe("workspace-write"); expect(resumed.permissionMode).toBe("default"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 3b4dc4e86..4ee6c569d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1233,15 +1233,29 @@ const CLAUDE_THINKING_SETTINGS = { const KNOWN_CLAUDE_EFFORTS = new Set(CLAUDE_REASONING_EFFORTS.map((e) => e.effort)); -const CODEX_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProvider("codex").map((descriptor) => ({ - id: descriptor.providerModelId, - displayName: descriptor.displayName, - description: describeCodexModel(descriptor.displayName), - isDefault: descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id, - reasoningEfforts: descriptor.reasoningTiers?.length - ? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort)) - : CODEX_REASONING_EFFORTS, -})); +function codexModelInfoFromDescriptor( + descriptor: ModelDescriptor, + overrides?: Partial>, +): AgentChatModelInfo { + return { + id: descriptor.providerModelId, + displayName: descriptor.displayName, + description: overrides?.description ?? describeCodexModel(descriptor.displayName), + isDefault: overrides?.isDefault ?? descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id, + reasoningEfforts: descriptor.reasoningTiers?.length + ? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort)) + : CODEX_REASONING_EFFORTS, + modelId: descriptor.id, + family: descriptor.family, + supportsReasoning: descriptor.capabilities.reasoning, + supportsTools: descriptor.capabilities.tools, + color: descriptor.color, + }; +} + +const CODEX_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProvider("codex").map((descriptor) => + codexModelInfoFromDescriptor(descriptor) +); const CLAUDE_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProvider("claude").map((descriptor) => ({ id: descriptor.providerModelId, @@ -1368,6 +1382,17 @@ function validateReasoningEffort(provider: "codex" | "claude", effort: string | return known.has(aliased) ? aliased : fallback; } +function resolveCodexReasoningEffortForRuntime( + primary: string | null | undefined, + fallback?: string | null, +): string { + return ( + validateReasoningEffort("codex", normalizeReasoningEffort(primary)) + ?? validateReasoningEffort("codex", normalizeReasoningEffort(fallback)) + ?? DEFAULT_REASONING_EFFORT + ); +} + function buildClaudeV2ExecutableArgs(args: { supportsReasoning: boolean; effort?: string | null; @@ -2292,16 +2317,36 @@ function codexSandboxPolicyType(sandbox: AgentChatCodexSandbox): string { } } +function codexApprovalPolicyWireValue(approvalPolicy: AgentChatCodexApprovalPolicy): string { + switch (approvalPolicy) { + case "untrusted": + return "unlessTrusted"; + case "on-request": + return "onRequest"; + case "on-failure": + return "onFailure"; + case "never": + return "never"; + default: + return approvalPolicy satisfies never; + } +} + /** Spread-ready codex thread lifecycle policy args or empty object if null. */ function codexPolicyArgs(policy: ReturnType): Record { - return policy ? { approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox } : {}; + return policy + ? { + approvalPolicy: codexApprovalPolicyWireValue(policy.approvalPolicy), + sandbox: codexSandboxPolicyType(policy.sandbox), + } + : {}; } /** Spread-ready codex per-turn policy args or empty object if null. */ function codexTurnPolicyArgs(policy: ReturnType): Record { return policy ? { - approvalPolicy: policy.approvalPolicy, + approvalPolicy: codexApprovalPolicyWireValue(policy.approvalPolicy), sandboxPolicy: { type: codexSandboxPolicyType(policy.sandbox) }, } : {}; @@ -2320,6 +2365,13 @@ const CODEX_SANDBOX_CAMEL_CASE_ALIASES: Record = dangerFullAccess: "danger-full-access", }; +const CODEX_APPROVAL_POLICY_ALIASES: Record = { + unlessTrusted: "untrusted", + onRequest: "on-request", + onFailure: "on-failure", + never: "never", +}; + function normalizeCodexRuntimeSandbox(value: unknown): AgentChatCodexSandbox | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -2397,7 +2449,9 @@ function normalizePersistedClaudePermissionMode(value: unknown): AgentChatClaude } function normalizePersistedCodexApprovalPolicy(value: unknown): AgentChatCodexApprovalPolicy | undefined { - return normalizePersistedEnum(value, VALID_CODEX_APPROVAL_POLICIES); + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return normalizePersistedEnum(trimmed, VALID_CODEX_APPROVAL_POLICIES) ?? CODEX_APPROVAL_POLICY_ALIASES[trimmed]; } function normalizePersistedCodexSandbox(value: unknown): AgentChatCodexSandbox | undefined { @@ -10562,9 +10616,15 @@ export function createAgentChatService(args: { runtime: CodexRuntime, codexPolicy: CodexPolicy, ): Promise => { + const reasoningEffort = validateReasoningEffort( + "codex", + normalizeReasoningEffort(managed.session.reasoningEffort), + ) ?? DEFAULT_REASONING_EFFORT; + managed.session.reasoningEffort = reasoningEffort; const startResponse = await runtime.request("thread/start", { model: managed.session.model, cwd: managed.laneWorktreePath, + reasoningEffort, ...codexPolicyArgs(codexPolicy), experimentalRawEvents: false, persistExtendedHistory: true @@ -11313,7 +11373,7 @@ export function createAgentChatService(args: { runtime = await startCodexRuntime(tempSession); const response = await runtime.request<{ data?: Array> }>("model/list", {}); const rows = Array.isArray(response?.data) ? response.data : []; - const models = rows + const appServerModels = rows .map((row): AgentChatModelInfo | null => { const id = typeof row.id === "string" ? row.id.trim() : ""; if (!id) return null; @@ -11362,18 +11422,38 @@ export function createAgentChatService(args: { }) .filter((entry): entry is AgentChatModelInfo => entry != null); - if (models.length) { - const preferredIdx = models.findIndex((entry) => entry.id === DEFAULT_CODEX_MODEL); - if (preferredIdx >= 0) { - return models.map((entry, index) => ({ + if (appServerModels.length) { + const byRegistryId = new Map(); + const extras: AgentChatModelInfo[] = []; + for (const entry of appServerModels) { + const descriptor = resolveModelDescriptorForProvider(entry.id, "codex"); + if (descriptor) { + byRegistryId.set(descriptor.id, entry); + } else { + extras.push(entry); + } + } + + const ordered = listModelDescriptorsForProvider("codex") + .filter((descriptor) => byRegistryId.has(descriptor.id)) + .map((descriptor) => { + const appServerEntry = byRegistryId.get(descriptor.id); + return codexModelInfoFromDescriptor(descriptor, { + description: appServerEntry?.description ?? describeCodexModel(descriptor.displayName), + isDefault: descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id, + }); + }); + + const preferredIds = new Set(ordered.map((entry) => entry.id)); + const dedupedExtras = extras.filter((entry) => !preferredIds.has(entry.id)); + const result = [...ordered, ...dedupedExtras]; + if (result.length) { + const hasRegistryDefault = result.some((entry) => entry.modelId === DEFAULT_CODEX_DESCRIPTOR?.id); + return result.map((entry, index) => ({ ...entry, - isDefault: index === preferredIdx, + isDefault: entry.modelId === DEFAULT_CODEX_DESCRIPTOR?.id || (!hasRegistryDefault && (entry.isDefault || index === 0)), })); } - if (!models.some((entry) => entry.isDefault)) { - models[0] = { ...models[0]!, isDefault: true }; - } - return models; } return CODEX_FALLBACK_MODELS; } catch { @@ -14912,10 +14992,16 @@ export function createAgentChatService(args: { if (threadIdToResume) { try { + const resumeReasoningEffort = resolveCodexReasoningEffortForRuntime( + managed.session.reasoningEffort, + readPersistedState(sessionId)?.reasoningEffort, + ); + managed.session.reasoningEffort = resumeReasoningEffort; const resumeResponse = await runtime.request("thread/resume", { threadId: threadIdToResume, model: managed.session.model, cwd: managed.laneWorktreePath, + reasoningEffort: resumeReasoningEffort, ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); @@ -15716,9 +15802,10 @@ export function createAgentChatService(args: { if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); - if (!managed.session.reasoningEffort) { - managed.session.reasoningEffort = persisted?.reasoningEffort ?? DEFAULT_REASONING_EFFORT; - } + managed.session.reasoningEffort = resolveCodexReasoningEffortForRuntime( + managed.session.reasoningEffort, + persisted?.reasoningEffort, + ); const threadId = persisted?.threadId ?? managed.session.threadId; if (threadId) { const { codexPolicy } = resolveCodexThreadParams(managed); @@ -15727,6 +15814,7 @@ export function createAgentChatService(args: { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, + reasoningEffort: managed.session.reasoningEffort, ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 8ef8b04f2..f9a627595 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -16,13 +16,9 @@ export const DROID_DEFAULT_MODEL_IDS: string[] = [ "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", "claude-haiku-4-5-20251001", - "gpt-5.1-codex", - "gpt-5.1-codex-max", "gpt-5.1", "gpt-5.2", - "gpt-5.2-codex", "gpt-5.3-codex", - "gpt-5.3-codex-fast", "gpt-5.4", "gpt-5.4-fast", "gpt-5.4-mini", diff --git a/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts b/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts index 861cb207d..165e11c05 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts @@ -175,7 +175,7 @@ describe("projectConfigService AI mode normalization", () => { }, chat: { autoTitleEnabled: true, - autoTitleModelId: "openai/gpt-5.3-codex-spark", + autoTitleModelId: "openai/gpt-5.4-mini", autoTitleRefreshOnComplete: false, autoAllowAskUser: false, codexSandbox: "workspace-write", @@ -197,7 +197,7 @@ describe("projectConfigService AI mode normalization", () => { expect(snapshot.effective.ai?.features?.commit_messages).toBe(true); expect(snapshot.effective.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); expect(snapshot.effective.ai?.sessionIntelligence?.titles?.enabled).toBe(true); - expect(snapshot.effective.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.3-codex-spark"); + expect(snapshot.effective.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); expect(snapshot.effective.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); expect(snapshot.effective.ai?.chat?.autoAllowAskUser).toBe(false); expect(snapshot.effective.ai?.chat?.codexSandbox).toBe("workspace-write"); @@ -214,7 +214,7 @@ describe("projectConfigService AI mode normalization", () => { expect(persisted.ai?.chat?.autoTitleModelId).toBeUndefined(); expect(persisted.ai?.chat?.autoTitleRefreshOnComplete).toBeUndefined(); expect(persisted.ai?.sessionIntelligence?.titles?.enabled).toBe(true); - expect(persisted.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.3-codex-spark"); + expect(persisted.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); expect(persisted.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); expect(persisted.ai?.chat?.autoAllowAskUser).toBe(false); expect(persisted.ai?.chat?.codexSandbox).toBe("workspace-write"); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index d6af368ec..e8c92b01c 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -164,10 +164,10 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ "", "ADE supports multiple AI providers and models. When spawning chats or configuring workers, use the correct modelId:", " Anthropic models (via Claude CLI): anthropic/claude-opus-4-7 (shortId: opus), anthropic/claude-sonnet-4-6 (shortId: sonnet), anthropic/claude-haiku-4-5 (shortId: haiku).", - " OpenAI models (via Codex CLI): openai/gpt-5.5-codex (shortId: gpt-5.5-codex), openai/gpt-5.4-codex, openai/gpt-5.4-mini-codex, openai/gpt-5.3-codex, openai/gpt-5.3-codex-spark, openai/gpt-5.2-codex, openai/gpt-5.1-codex-max, openai/gpt-5.1-codex-mini.", + " OpenAI models (via Codex CLI): openai/gpt-5.5 (shortId: gpt-5.5), openai/gpt-5.4, openai/gpt-5.4-mini, openai/gpt-5.3-codex.", " Local models: ollama/llama-3.3, lmstudio/* (discovered at runtime).", " Reasoning effort (for supported models): low, medium, high, max (opus), xhigh (openai).", - " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-7'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.5' → 'openai/gpt-5.5-codex'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", + " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-7'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.5' → 'openai/gpt-5.5'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", "", "## Critical Distinctions", "", diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts index 88c6cc7db..76ce6d38f 100644 --- a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -1202,7 +1202,7 @@ describe("workerAgentService (file group)", () => { role: "engineer", reportsTo: manager.id, adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex-spark" }, + adapterConfig: { model: "gpt-5.4-mini" }, capabilities: ["api", "tests"], }); expect(edited.name).toBe("Backend Engineer"); diff --git a/apps/desktop/src/main/services/missions/phaseEngine.test.ts b/apps/desktop/src/main/services/missions/phaseEngine.test.ts index 42a5f52b9..b8a45ca48 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.test.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.test.ts @@ -18,7 +18,7 @@ function makePhaseCard(overrides: Partial = {}): PhaseCard { name: "Development", description: "Implement planned work.", instructions: "Execute implementation tasks.", - model: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "medium" }, + model: { modelId: "openai/gpt-5.4", thinkingLevel: "medium" }, budget: {}, orderingConstraints: {}, askQuestions: { enabled: false }, diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index 2b77c1cd8..f54f1a9fe 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -28,7 +28,7 @@ export const BUILT_IN_PHASE_KEYS = { } as const; const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; +const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; const DEFAULT_MODELS: Record = { [BUILT_IN_PHASE_KEYS.planning]: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts index 1e2c68479..be8c2c623 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts @@ -1198,7 +1198,7 @@ describe("coordinatorTools planning manual-input blocking", () => { currentPhaseKey: "development", currentPhaseName: "Development", currentPhaseModel: { - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", thinkingLevel: "medium", }, }, @@ -1233,7 +1233,7 @@ describe("coordinatorTools planning manual-input blocking", () => { name: "Development", description: "Ship the code.", instructions: "Implement the work.", - model: { modelId: "openai/gpt-5.4-codex", provider: "openai", thinkingLevel: "medium" }, + model: { modelId: "openai/gpt-5.4", provider: "openai", thinkingLevel: "medium" }, budget: {}, orderingConstraints: {}, askQuestions: { enabled: false }, @@ -1269,7 +1269,7 @@ describe("coordinatorTools planning manual-input blocking", () => { currentPhaseKey: "development", currentPhaseName: "Development", currentPhaseModel: { - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", thinkingLevel: "medium", }, }, @@ -1287,7 +1287,7 @@ describe("coordinatorTools planning manual-input blocking", () => { name: "Development", description: "Ship the code.", instructions: "Implement the work.", - model: { modelId: "openai/gpt-5.4-codex", provider: "openai", thinkingLevel: "medium" }, + model: { modelId: "openai/gpt-5.4", provider: "openai", thinkingLevel: "medium" }, budget: {}, orderingConstraints: {}, askQuestions: { enabled: false }, diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts index 70c2ffa0d..3b619daae 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts @@ -82,7 +82,7 @@ describe("executionPolicy", () => { missionMetadata: { planning: { mode: "manual_review" } } }); expect(policy.planning.mode).toBe("manual_review"); - expect(policy.implementation.model).toBe("openai/gpt-5.5-codex"); // from default + expect(policy.implementation.model).toBe("openai/gpt-5.5"); // from default }); }); diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts index 062559f64..73abc8602 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts @@ -39,7 +39,7 @@ import { TERMINAL_STEP_STATUSES, filterExecutionSteps } from "./orchestratorCont // ───────────────────────────────────────────────────── const DEFAULT_CLAUDE_POLICY_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_POLICY_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; +const DEFAULT_CODEX_POLICY_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; export const DEFAULT_EXECUTION_POLICY: MissionExecutionPolicy = { planning: { mode: "auto", model: DEFAULT_CLAUDE_POLICY_MODEL_ID }, diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts index 4271117c2..164073a23 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts @@ -1264,59 +1264,48 @@ export function getModelCapabilities(): GetModelCapabilitiesResult { }, { provider: "codex", - modelId: "gpt-5.3-codex", - displayName: "GPT-5.3 Codex", - strengths: ["latest and most capable coding model", "excellent implementation", "testing", "code review"], - weaknesses: ["narrative prose", "complex architectural reasoning"], - costTier: "medium", + modelId: "gpt-5.5", + displayName: "GPT-5.5", + strengths: ["frontier coding", "complex implementation", "testing", "code review"], + weaknesses: ["higher latency", "not needed for simple edits"], + costTier: "high", bestFor: ["implementation", "review"], parallelCapable: true, reasoningTiers: ["low", "medium", "high", "xhigh"] }, { provider: "codex", - modelId: "gpt-5.3-codex-spark", - displayName: "GPT-5.3 Codex Spark", - strengths: ["real-time coding (>1000 tok/s)", "quick edits", "rapid iteration"], - weaknesses: ["limited reasoning depth", "not suited for complex multi-file refactors"], - costTier: "low", + modelId: "gpt-5.4", + displayName: "GPT-5.4", + strengths: ["strong implementation", "reliable coding tasks", "test writing"], + weaknesses: ["less capable than GPT-5.5 on complex tasks"], + costTier: "high", bestFor: ["implementation"], parallelCapable: true, reasoningTiers: ["low", "medium", "high", "xhigh"] }, { provider: "codex", - modelId: "gpt-5.2-codex", - displayName: "GPT-5.2 Codex", - strengths: ["strong implementation", "reliable for standard coding tasks", "test writing"], - weaknesses: ["previous generation", "less capable than 5.3 on complex tasks"], - costTier: "medium", + modelId: "gpt-5.4-mini", + displayName: "GPT-5.4-Mini", + strengths: ["fast coding", "quick edits", "lower-cost iteration"], + weaknesses: ["less capable than GPT-5.5 on complex multi-file refactors"], + costTier: "low", bestFor: ["implementation"], parallelCapable: true, reasoningTiers: ["low", "medium", "high", "xhigh"] }, { provider: "codex", - modelId: "gpt-5.1-codex-max", - displayName: "GPT-5.1 Codex Max", - strengths: ["extended context variant", "large files and repos", "multi-file understanding"], - weaknesses: ["older generation", "higher latency than newer models"], - costTier: "medium", - bestFor: ["implementation"], + modelId: "gpt-5.3-codex", + displayName: "GPT-5.3 Codex", + strengths: ["agentic coding", "implementation", "testing", "code review"], + weaknesses: ["older than GPT-5.5 for general tasks"], + costTier: "high", + bestFor: ["implementation", "review"], parallelCapable: true, reasoningTiers: ["low", "medium", "high", "xhigh"] }, - { - provider: "codex", - modelId: "codex-mini-latest", - displayName: "Codex Mini", - strengths: ["small fast model", "simple tasks", "quick fixes"], - weaknesses: ["limited capability on complex tasks", "smaller context window"], - costTier: "low", - bestFor: ["implementation"], - parallelCapable: true, - reasoningTiers: ["medium", "high"] - }, { provider: "codex", modelId: "o4-mini", diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts index 068165713..ca52d549a 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts @@ -90,7 +90,7 @@ function makePhaseCard(overrides: Partial = {}): PhaseCard { name: "Development", description: "Implement features", instructions: "Write clean code and tests.", - model: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "medium" }, + model: { modelId: "openai/gpt-5.4", thinkingLevel: "medium" }, budget: {}, orderingConstraints: {}, askQuestions: { enabled: false }, diff --git a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts index 60241c020..189d8ccdf 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts @@ -2548,7 +2548,7 @@ describe("launchPrIssueResolutionChat", () => { const result = await previewPrIssueResolutionPrompt(deps as any, { prId: pr.id, scope: "checks", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoning: "high", permissionMode: "guarded_edit", additionalInstructions: "Keep commits tight and rerun focused tests first.", @@ -2566,7 +2566,7 @@ describe("launchPrIssueResolutionChat", () => { const result = await previewPrIssueResolutionPrompt(deps as any, { prId: pr.id, scope: "checks", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoning: "high", permissionMode: "guarded_edit", additionalInstructions: null, @@ -2590,7 +2590,7 @@ describe("launchPrIssueResolutionChat", () => { const result = await launchPrIssueResolutionChat(deps as any, { prId: pr.id, scope: "checks", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoning: "high", permissionMode: "guarded_edit", additionalInstructions: "Run focused tests before full CI.", @@ -2600,7 +2600,7 @@ describe("launchPrIssueResolutionChat", () => { laneId: lane.id, provider: "codex", model: "gpt-5.4", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", surface: "work", sessionProfile: "workflow", permissionMode: "default", @@ -2643,7 +2643,7 @@ describe("launchPrIssueResolutionChat", () => { await expect(launchPrIssueResolutionChat(deps as any, { prId: pr.id, scope: "checks", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", })).rejects.toThrow("Failing checks are not currently actionable"); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2fc4cab97..ab983f02a 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -414,7 +414,7 @@ describe("ptyService", () => { rows: 24, command: "npm", args: ["run", "dev"], - env: { CUSTOM_FLAG: "1" }, + env: { CUSTOM_FLAG: "1", TERM: "", COLORTERM: "", FORCE_COLOR: "", NO_COLOR: "" }, }); const ptyLib = harness.loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; @@ -426,11 +426,62 @@ describe("ptyService", () => { PORT: "3100", HOSTNAME: "lane-1.localhost", CUSTOM_FLAG: "1", + TERM: "xterm-256color", + COLORTERM: "truecolor", + FORCE_COLOR: "1", }), }), ); }); + it("preserves explicit terminal color environment overrides", async () => { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Color env", + cols: 80, + rows: 24, + env: { + TERM: "screen-256color", + COLORTERM: "24bit", + FORCE_COLOR: "2", + }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + TERM: "screen-256color", + COLORTERM: "24bit", + FORCE_COLOR: "2", + })); + }); + + it("does not force color when NO_COLOR is set", async () => { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "No color env", + cols: 80, + rows: 24, + env: { + NO_COLOR: "1", + FORCE_COLOR: "", + }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + NO_COLOR: "1", + FORCE_COLOR: "", + })); + }); + it("does not type startupCommand preview into direct command sessions", async () => { const { service, mockPty } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index af3b5e9cf..95c85c2c4 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -59,6 +59,25 @@ const PTY_DATA_BATCH_INTERVAL_MS = 16; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; +function hasEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { + return typeof env[key] === "string" && env[key]!.trim().length > 0; +} + +function withInteractiveTerminalColorEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...env }; + const term = next.TERM?.trim().toLowerCase() ?? ""; + if (!term || term === "dumb") { + next.TERM = "xterm-256color"; + } + if (!hasEnvValue(next, "COLORTERM")) { + next.COLORTERM = "truecolor"; + } + if (!hasEnvValue(next, "NO_COLOR") && !hasEnvValue(next, "FORCE_COLOR")) { + next.FORCE_COLOR = "1"; + } + return next; +} + function sanitizeCliUserTitleSeed(raw: string): string { const stripped = stripAnsi(raw) .replace(/\r\n/g, "\n") @@ -1537,7 +1556,7 @@ export function createPtyService({ ...((await getLaneRuntimeEnv?.(laneId)) ?? {}), ...(args.env ?? {}) }; - const launchEnv = getAdeCliAgentEnv?.(baseLaunchEnv) ?? baseLaunchEnv; + const launchEnv = withInteractiveTerminalColorEnv(getAdeCliAgentEnv?.(baseLaunchEnv) ?? baseLaunchEnv); const shouldBackfillResumeTarget = existingSession && isTrackedCliToolType(toolTypeHint) diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts index 24b74119b..d700c9675 100644 --- a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts @@ -134,7 +134,7 @@ function makeRun() { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoningEffort: "medium", budgets: { maxFiles: 60, diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index ac7ef3d75..bf1c46413 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -189,7 +189,7 @@ function makeConfig(overrides: Partial = {}): ReviewRunConfig { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoningEffort: "medium", budgets: { maxFiles: 60, @@ -493,8 +493,8 @@ function createHarness(args: { return { sessionId: `session-review-${sessionCount}`, provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", outputText, }; }); @@ -530,16 +530,16 @@ function createHarness(args: { id: `session-review-${sessionCount}`, laneId: "lane-review", provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", }; }), getSessionSummary: vi.fn(async (sessionId: string) => ({ sessionId, laneId: "lane-review", provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", title: "Review transcript", surface: "automation", status: "idle", diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index b9a1c9686..29da7806d 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -141,7 +141,7 @@ type ReviewRunPublicationRow = { completed_at: string | null; }; -const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4-codex"; +const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4"; function resolveBuiltinReviewModelId(): string { const candidates = [ diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts index 8941e708d..e462fa756 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts @@ -21,7 +21,7 @@ function makeConfig(overrides: Partial = {}): ReviewRunConfig { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoningEffort: "medium", budgets: { maxFiles: 60, diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 6f87bcc9c..4b6581d4a 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -116,6 +116,7 @@ async function connectClient(args: { dbVersion: number; platform?: "macOS" | "linux" | "windows" | "iOS" | "unknown"; deviceType?: "desktop" | "phone" | "vps" | "unknown"; + capabilities?: string[]; }) { const ws = new WebSocket(`ws://127.0.0.1:${args.port}`); await new Promise((resolve, reject) => { @@ -135,6 +136,7 @@ async function connectClient(args: { deviceType: args.deviceType ?? "desktop", siteId: args.siteId, dbVersion: args.dbVersion, + capabilities: args.capabilities ?? ["changesetAck"], }, }, compressionThresholdBytes: 100_000, @@ -925,6 +927,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const brainDb = await openKvDb(makeDbPath("ade-sync-brain-"), createLogger() as any); const dbA = await openKvDb(makeDbPath("ade-sync-peer-a-"), createLogger() as any); const dbB = await openKvDb(makeDbPath("ade-sync-peer-b-"), createLogger() as any); + const dbC = await openKvDb(makeDbPath("ade-sync-peer-c-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-host-project-"); const workspaceRoot = path.join(projectRoot, "workspace"); fs.mkdirSync(workspaceRoot, { recursive: true }); @@ -1025,6 +1028,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { brainDb.close(); dbA.close(); dbB.close(); + dbC.close(); }); const port = await host.waitUntilListening(); @@ -1054,6 +1058,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { type: "changeset_batch", requestId: "changes-a", payload: { + batchId: "changes-a", reason: "relay", fromDbVersion: beforeVersion, toDbVersion: dbA.sync.getDbVersion(), @@ -1066,12 +1071,55 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const replicated = brainDb.getJson<{ value: string }>("replicated-state"); return replicated?.value === "hello"; }); + const sourceAck = await clientA.queue.next("changeset_ack"); + expect((sourceAck.payload as { ok: boolean; batchId?: string }).ok).toBe(true); + expect((sourceAck.payload as { ok: boolean; batchId?: string }).batchId).toBe("changes-a"); const rebroadcast = await clientB.queue.next("changeset_batch"); - const payload = rebroadcast.payload as { changes: unknown[] }; + const payload = rebroadcast.payload as { + batchId: string; + fromDbVersion: number; + toDbVersion: number; + changes: unknown[]; + }; expect(payload.changes.length).toBeGreaterThan(0); dbB.sync.applyChanges(payload.changes as any); expect(dbB.getJson<{ value: string }>("replicated-state")).toEqual({ value: "hello" }); + expect(host.getPeerStates().find((peer) => peer.deviceId === "peer-b")?.syncLag).toBeGreaterThan(0); + clientB.ws.send(encodeSyncEnvelope({ + type: "changeset_ack", + requestId: payload.batchId, + payload: { + batchId: payload.batchId, + fromDbVersion: payload.fromDbVersion, + toDbVersion: payload.toDbVersion, + appliedDbVersion: dbB.sync.getDbVersion(), + appliedCount: payload.changes.length, + ok: true, + }, + compressionThresholdBytes: 100_000, + })); + await waitFor(() => host.getPeerStates().find((peer) => peer.deviceId === "peer-b")?.syncLag === 0); + + const legacyClient = await connectClient({ + port, + token, + deviceId: "peer-legacy", + deviceName: "Peer Legacy", + siteId: dbC.sync.getSiteId(), + dbVersion: 0, + capabilities: [], + }); + activeDisposers.push(legacyClient.close); + const legacyBatch = await legacyClient.queue.next("changeset_batch"); + const legacyPayload = legacyBatch.payload as { + batchId: string; + toDbVersion: number; + changes: unknown[]; + }; + expect(legacyPayload.batchId).toBeTruthy(); + expect(legacyPayload.changes.length).toBeGreaterThan(0); + await waitFor(() => host.getPeerStates().find((peer) => peer.deviceId === "peer-legacy")?.syncLag === 0); }, 60_000); it("serves workspace file operations and artifact reads while blocking .git access", async () => { @@ -1536,6 +1584,51 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const result = await client.queue.next("command_result"); expect((result.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1"); expect(createSpy).toHaveBeenCalledTimes(1); + await waitFor(() => fs.existsSync(path.join(projectRoot, ".ade", "cache", "sync-mobile-command-ledger.json"))); + const commandLedgerPath = path.join(projectRoot, ".ade", "cache", "sync-mobile-command-ledger.json"); + const quickRunLedger = fs.readFileSync(commandLedgerPath, "utf8"); + expect(quickRunLedger).toContain("cmd-quick-run"); + expect(quickRunLedger).toContain("argsFingerprint"); + expect(quickRunLedger).not.toContain("argsKey"); + expect(quickRunLedger).not.toContain("npm test"); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-quick-run-retry", + payload: { + commandId: "cmd-quick-run", + action: "work.runQuickCommand", + args: { + laneId: "lane-1", + title: "Run tests", + startupCommand: "npm test", + }, + }, + })); + const replayAck = await client.queue.next("command_ack"); + expect((replayAck.payload as { accepted: boolean }).accepted).toBe(true); + const replayResult = await client.queue.next("command_result"); + expect((replayResult.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1"); + expect(createSpy).toHaveBeenCalledTimes(1); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-quick-run-conflict", + payload: { + commandId: "cmd-quick-run", + action: "work.runQuickCommand", + args: { + laneId: "lane-2", + title: "Run a different command", + startupCommand: "npm run lint", + }, + }, + })); + const mismatchAck = await client.queue.next("command_ack"); + expect((mismatchAck.payload as { accepted: boolean }).accepted).toBe(false); + const mismatchResult = await client.queue.next("command_result"); + expect((mismatchResult.payload as { ok: boolean; error?: { code: string } }).error?.code).toBe("duplicate_command_mismatch"); + expect(createSpy).toHaveBeenCalledTimes(1); client.ws.send(encodeSyncEnvelope({ type: "command", @@ -1551,6 +1644,9 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const workListResult = await client.queue.next("command_result"); const workSessions = (workListResult.payload as { ok: boolean; result: Array<{ id: string }> }).result; expect(workSessions.map((entry) => entry.id)).toEqual(["session-1"]); + const afterWorkListLedger = fs.readFileSync(commandLedgerPath, "utf8"); + expect(afterWorkListLedger).not.toContain("cmd-work-list"); + expect(afterWorkListLedger).not.toContain("prior output"); client.ws.send(encodeSyncEnvelope({ type: "command", diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index f2700aa12..ac86d8561 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { Bonjour, type Service as BonjourService } from "bonjour-service"; import { WebSocketServer, WebSocket, type RawData } from "ws"; import { resolveAdeLayout } from "../../../shared/adeLayout"; @@ -23,7 +23,9 @@ import type { PtyDataEvent, PtyExitEvent, SyncBrainStatusPayload, + SyncChangesetAckPayload, SyncChangesetBatchPayload, + SyncCommandAckPayload, SyncCommandPayload, SyncCommandResultPayload, SyncEnvelope, @@ -77,7 +79,7 @@ import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createSessionService } from "../sessions/sessionService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import type { AdeDb } from "../state/kvDb"; -import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, toOptionalString, uniqueStrings } from "../shared/utils"; +import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../shared/utils"; import type { DeviceRegistryService } from "./deviceRegistryService"; import { createSyncPairingStore } from "./syncPairingStore"; import type { NotificationEventBus } from "../notifications/notificationEventBus"; @@ -101,6 +103,10 @@ const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; +const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; +const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; const LANE_PRESENCE_TTL_MS = 60_000; const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; @@ -138,8 +144,134 @@ type PeerState = { subscribedChatSessionIds: Set; chatTranscriptOffsets: Map; chatEventIdsSent: Map>; + pendingChangesetBatch: PendingChangesetBatch | null; }; +type PendingChangesetBatch = { + batchId: string; + fromDbVersion: number; + toDbVersion: number; + changes: CrsqlChangeRow[]; + reason: SyncChangesetBatchPayload["reason"]; + sentAtMs: number; + retryCount: number; +}; + +type CachedMobileCommandWaiter = { + peer: PeerState; + requestId: string | null; +}; + +type CachedMobileCommand = { + commandId: string; + action: string; + argsKey: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload | null; + waiters: CachedMobileCommandWaiter[]; + acceptedAtMs: number; + completedAtMs: number | null; +}; + +type PersistedMobileCommand = { + key: string; + projectRoot: string; + deviceId: string; + commandId: string; + action: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload; + acceptedAtMs: number; + completedAtMs: number; +}; + +const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ + "lanes.presence.announce", + "lanes.presence.release", + "notification_prefs", + "work.runQuickCommand", + "work.closeSession", + "processes.start", + "processes.stop", + "processes.kill", + "chat.interrupt", + "chat.approve", + "chat.respondToInput", + "chat.dispose", + "chat.archive", + "chat.unarchive", + "chat.delete", +]); + +function stableJsonValue(value: unknown): unknown { + if (value == null) return value; + if (Array.isArray(value)) return value.map(stableJsonValue); + if (typeof value !== "object") return value; + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input).sort()) { + output[key] = stableJsonValue(input[key]); + } + return output; +} + +function stableJsonKey(value: unknown): string { + return JSON.stringify(stableJsonValue(value)) ?? "null"; +} + +function mobileCommandArgsFingerprint(argsKey: string): string { + return createHash("sha256").update(argsKey).digest("hex"); +} + +function safeObjectValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function persistedMobileCommandResult(action: string, result: SyncCommandResultPayload): SyncCommandResultPayload | null { + if (!PERSISTED_MOBILE_COMMAND_ACTIONS.has(action)) return null; + if (!result.ok) { + return { + commandId: result.commandId, + ok: false, + error: { + code: result.error?.code ?? "command_failed", + message: "Command failed before reconnect.", + }, + }; + } + if (action === "work.runQuickCommand") { + const raw = safeObjectValue(result.result); + const replayResult: Record = {}; + if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; + if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; + return { + commandId: result.commandId, + ok: true, + result: Object.keys(replayResult).length > 0 ? replayResult : { ok: true }, + }; + } + return { + commandId: result.commandId, + ok: true, + result: { ok: true }, + }; +} + +function mobileCommandCacheKey(projectRoot: string, peer: PeerState, commandId: string): string | null { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId; + if (!deviceId || !commandId) return null; + return `${projectRoot}:${deviceId}:${commandId}`; +} + +function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, requestId: string | null): void { + if (record.waiters.some((waiter) => waiter.peer === peer && waiter.requestId === requestId)) return; + record.waiters.push({ peer, requestId }); +} + type SyncHostServiceArgs = { db: AdeDb; logger: Logger; @@ -315,6 +447,12 @@ function parseHelloPayload(payload: unknown): SyncHelloPayload | null { deviceType: peer.deviceType ?? "unknown", siteId: String(peer.siteId).trim(), dbVersion: Number(peer.dbVersion ?? 0), + capabilities: Array.isArray(peer.capabilities) + ? peer.capabilities + .filter((capability): capability is string => typeof capability === "string") + .map((capability) => capability.trim()) + .filter(Boolean) + : [], }, auth: normalizedAuth, }; @@ -355,6 +493,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); const pairingSecretsPath = args.pairingSecretsPath ?? path.join(layout.secretsDir, "sync-paired-devices.json"); + const commandLedgerPath = path.join(layout.cacheDir, "sync-mobile-command-ledger.json"); const bootstrapToken = ensureBootstrapToken(bootstrapTokenPath); const pairingStore = createSyncPairingStore({ filePath: pairingSecretsPath, @@ -423,6 +562,107 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }; const peers = new Set(); + const mobileCommandResultCache = new Map(); + let commandReplayCount = 0; + let commandConflictCount = 0; + let lastCommandResultLatencyMs: number | null = null; + let lastChangesetAckLatencyMs: number | null = null; + + const pruneMobileCommandResultCache = (nowMs = Date.now()): void => { + for (const [key, record] of mobileCommandResultCache) { + if (record.completedAtMs == null) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) { + mobileCommandResultCache.delete(key); + } + } + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) return; + + const completed = [...mobileCommandResultCache.entries()] + .filter(([, record]) => record.completedAtMs != null) + .sort(([, left], [, right]) => (left.completedAtMs ?? left.acceptedAtMs) - (right.completedAtMs ?? right.acceptedAtMs)); + for (const [key] of completed) { + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) break; + mobileCommandResultCache.delete(key); + } + }; + + const readPersistedCommandLedger = (): PersistedMobileCommand[] => { + try { + if (!fs.existsSync(commandLedgerPath)) return []; + const parsed = safeJsonParse<{ commands?: PersistedMobileCommand[] }>( + fs.readFileSync(commandLedgerPath, "utf8"), + { commands: [] }, + ); + return Array.isArray(parsed.commands) ? parsed.commands : []; + } catch (error) { + args.logger.warn("sync_host.command_ledger_read_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + }; + const writePersistedCommandLedger = (): void => { + const nowMs = Date.now(); + const commands: PersistedMobileCommand[] = []; + for (const [key, record] of mobileCommandResultCache) { + if (!record.result || record.completedAtMs == null) continue; + const persistedResult = persistedMobileCommandResult(record.action, record.result); + if (!persistedResult) continue; + if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; + commands.push({ + key, + projectRoot: args.projectRoot, + deviceId, + commandId: record.commandId, + action: record.action, + argsFingerprint: record.argsFingerprint, + ack: record.ack, + result: persistedResult, + acceptedAtMs: record.acceptedAtMs, + completedAtMs: record.completedAtMs, + }); + } + commands.sort((left, right) => right.completedAtMs - left.completedAtMs); + writeTextAtomic(commandLedgerPath, `${JSON.stringify({ commands: commands.slice(0, MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) }, null, 2)}\n`); + }; + const loadPersistedCommandLedger = (): void => { + const nowMs = Date.now(); + for (const command of readPersistedCommandLedger()) { + if (command.projectRoot !== args.projectRoot) continue; + if (nowMs - command.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const replayResult = persistedMobileCommandResult(command.action, command.result); + if (!replayResult) continue; + const legacyArgsKey = (command as { argsKey?: unknown }).argsKey; + const argsFingerprint = typeof command.argsFingerprint === "string" + ? command.argsFingerprint + : typeof legacyArgsKey === "string" + ? mobileCommandArgsFingerprint(legacyArgsKey) + : null; + if (!argsFingerprint) continue; + mobileCommandResultCache.set(command.key, { + commandId: command.commandId, + action: command.action, + argsKey: argsFingerprint, + argsFingerprint, + ack: command.ack, + result: replayResult, + waiters: [], + acceptedAtMs: command.acceptedAtMs, + completedAtMs: command.completedAtMs, + }); + } + }; + const commandLedgerSizeForProject = (): number => + [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; + const dropInFlightCommandRecordsForProject = (): void => { + for (const [key, record] of mobileCommandResultCache) { + if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (record.result == null) mobileCommandResultCache.delete(key); + } + }; + loadPersistedCommandLedger(); /** Notification preferences keyed by deviceId. The map is a hot cache; * device metadata is the restart-safe source for offline push fan-out. */ const notificationPrefsByDeviceId = new Map(); @@ -817,6 +1057,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { subscribedChatSessionIds: new Set(), chatTranscriptOffsets: new Map(), chatEventIdsSent: new Map(), + pendingChangesetBatch: null, }; peers.add(peer); ws.on("message", (raw) => { @@ -1103,17 +1344,33 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }; - function send(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void { + function send(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { const ws = target instanceof WebSocket ? target : target.ws; - if (ws.readyState !== WebSocket.OPEN) return; + if (ws.readyState !== WebSocket.OPEN) return false; // Drop sends to backpressured peers as the default — most envelopes are // either replayable (chat events / changesets re-derived from db state) or // tolerable to lose (acks, status pings). Routes that *must* deliver under // backpressure should call ws.send / sendAndWait directly. if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) { - return; + return false; } ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); + return true; + } + + function sendRequired(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { + const ws = peer.ws; + if (ws.readyState !== WebSocket.OPEN) return false; + ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), (error) => { + if (!error) return; + args.logger.warn("sync_host.required_send_failed", { + type, + requestId: requestId ?? null, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + error: error.message, + }); + }); + return true; } function isPeerBackpressured(peer: PeerState): boolean { @@ -1166,52 +1423,75 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } - function sendChangesetBatch( + function makeChangesetBatchId(peer: PeerState, fromDbVersion: number, toDbVersion: number): string { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? "peer"; + return `changeset:${deviceId}:${fromDbVersion}:${toDbVersion}:${Date.now()}:${randomBytes(4).toString("hex")}`; + } + + function peerSupportsChangesetAck(peer: PeerState): boolean { + return Array.isArray(peer.metadata?.capabilities) && peer.metadata.capabilities.includes("changesetAck"); + } + + function sendNextChangesetBatch( peer: PeerState, reason: SyncChangesetBatchPayload["reason"], fromDbVersion: number, toDbVersion: number, changes: CrsqlChangeRow[], - ): void { + ): PendingChangesetBatch | null { let chunk: CrsqlChangeRow[] = []; - let chunkFromDbVersion = fromDbVersion; let chunkBytes = 0; - const flush = (): void => { - if (chunk.length === 0) return; - const chunkToDbVersion = Math.max(...chunk.map((change) => Number(change.db_version ?? chunkFromDbVersion))); - send(peer.ws, "changeset_batch", { - reason, - fromDbVersion: chunkFromDbVersion, - toDbVersion: chunkToDbVersion, - changes: chunk, - }); - chunkFromDbVersion = chunkToDbVersion; - chunk = []; - chunkBytes = 0; - }; - for (const change of changes) { const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); if ( chunk.length > 0 && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) ) { - flush(); + break; } chunk.push(change); chunkBytes += changeBytes; } - flush(); + if (chunk.length === 0 && changes.length > 0) { + chunk = [changes[0]!]; + } + if (chunk.length === 0 && toDbVersion <= fromDbVersion) return null; + + const chunkToDbVersion = chunk.length > 0 + ? Math.max(...chunk.map((change) => Number(change.db_version ?? fromDbVersion))) + : toDbVersion; + const batch: PendingChangesetBatch = { + batchId: makeChangesetBatchId(peer, fromDbVersion, chunkToDbVersion), + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + sentAtMs: Date.now(), + retryCount: 0, + }; + const sent = send(peer, "changeset_batch", { + batchId: batch.batchId, + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + }); + return sent ? batch : null; + } - if (changes.length === 0 && toDbVersion > fromDbVersion) { - send(peer.ws, "changeset_batch", { - reason, - fromDbVersion, - toDbVersion, - changes: [], - }); - } + function resendPendingChangesetBatch(peer: PeerState): boolean { + const batch = peer.pendingChangesetBatch; + if (!batch) return false; + batch.sentAtMs = Date.now(); + batch.retryCount += 1; + return send(peer, "changeset_batch", { + batchId: batch.batchId, + reason: batch.reason, + fromDbVersion: batch.fromDbVersion, + toDbVersion: batch.toDbVersion, + changes: batch.changes, + }); } async function buildProjectCatalogPayload(): Promise { @@ -1308,7 +1588,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { payload: SyncProjectSwitchRequestPayload | null, ): Promise { if (!args.projectCatalogProvider) { - send(peer.ws, "project_switch_result", { + sendRequired(peer, "project_switch_result", { ok: false, message: "Desktop project switching is not available.", }, requestId); @@ -1327,7 +1607,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } catch (error) { const message = error instanceof Error ? error.message : String(error); args.logger.warn("sync_host.project_switch_failed", { message }); - send(peer.ws, "project_switch_result", { + sendRequired(peer, "project_switch_result", { ok: false, message, }, requestId); @@ -1346,6 +1626,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { dbVersion: brainMetadata.dbVersion, uptimeMs: Date.now() - startedAtMs, lastBroadcastAt, + pendingChangesetPeerCount: 0, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, }, }; } @@ -1365,6 +1651,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { dbVersion, uptimeMs: Date.now() - startedAtMs, lastBroadcastAt, + pendingChangesetPeerCount: [...peers].filter((peer) => peer.pendingChangesetBatch != null).length, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, }, }; } @@ -1473,19 +1765,107 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async function pumpChanges(): Promise { if (disposed) return; const currentDbVersion = args.db.sync.getDbVersion(); + const nowMs = Date.now(); for (const peer of peers) { if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; if (isPeerBackpressured(peer)) continue; + if (peer.pendingChangesetBatch) { + if (nowMs - peer.pendingChangesetBatch.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + const pending = peer.pendingChangesetBatch; + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_host.changeset_ack_timeout", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + }); + try { + peer.ws.close(4000, "Changeset acknowledgement timed out"); + } catch { + // ignore close failures + } + continue; + } + const resent = resendPendingChangesetBatch(peer); + args.logger.debug("sync_host.changeset_ack_retry", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + resent, + }); + } + continue; + } if (currentDbVersion <= peer.lastKnownServerDbVersion) continue; const changes = args.db.sync .exportChangesSince(peer.lastKnownServerDbVersion) .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); - if (changes.length > 0) { - sendChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); + const pending = sendNextChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); + if (pending) { + if (peerSupportsChangesetAck(peer)) { + peer.pendingChangesetBatch = pending; + } else { + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + } lastBroadcastAt = nowIso(); + } else { + args.logger.debug("sync_host.changeset_deferred_backpressure", { + peerDeviceId: peer.metadata?.deviceId ?? null, + fromDbVersion: peer.lastKnownServerDbVersion, + toDbVersion: currentDbVersion, + bufferedAmount: peer.ws.bufferedAmount, + }); + } + } + } + + function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { + const pending = peer.pendingChangesetBatch; + if (!pending || !payload) return; + if (payload.batchId !== pending.batchId) { + args.logger.debug("sync_host.changeset_ack_ignored", { + peerDeviceId: peer.metadata?.deviceId ?? null, + expectedBatchId: pending.batchId, + receivedBatchId: payload.batchId, + }); + return; + } + if (!payload.ok) { + pending.retryCount += 1; + pending.sentAtMs = Date.now(); + args.logger.warn("sync_host.changeset_ack_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + error: payload.error?.message ?? "Changeset apply failed.", + }); + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + try { + peer.ws.close(4000, "Changeset apply failed repeatedly"); + } catch { + // ignore close failures + } } - peer.lastKnownServerDbVersion = currentDbVersion; + return; } + if (payload.toDbVersion < pending.toDbVersion) return; + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + peer.pendingChangesetBatch = null; + peer.lastAppliedAt = nowIso(); + lastChangesetAckLatencyMs = Math.max(0, Date.now() - pending.sentAtMs); + args.logger.debug("sync_host.changeset_ack_applied", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + latencyMs: lastChangesetAckLatencyMs, + }); + broadcastBrainStatus(); } function resolveArtifactPath(request: Extract["args"]): string { @@ -1551,7 +1931,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { const respond = (response: SyncFileResponsePayload) => { - send(peer.ws, "file_response", response, requestId); + sendRequired(peer, "file_response", response, requestId); }; try { @@ -1629,13 +2009,87 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async function handleCommand(peer: PeerState, requestId: string | null, payload: SyncCommandPayload): Promise { const commandId = toOptionalString(payload.commandId) ?? requestId ?? `cmd-${Date.now()}`; + const commandCacheKey = mobileCommandCacheKey(args.projectRoot, peer, commandId); + const commandArgsKey = stableJsonKey(payload.args ?? {}); + const commandArgsFingerprint = mobileCommandArgsFingerprint(commandArgsKey); + pruneMobileCommandResultCache(); + + const sendResult = (record: CachedMobileCommand | null, result: SyncCommandResultPayload) => { + if (!record) { + sendRequired(peer, "command_result", result, requestId); + return; + } + record.result = result; + record.completedAtMs = Date.now(); + lastCommandResultLatencyMs = Math.max(0, record.completedAtMs - record.acceptedAtMs); + const waiters = record.waiters.splice(0); + for (const waiter of waiters) { + sendRequired(waiter.peer, "command_result", result, waiter.requestId); + } + pruneMobileCommandResultCache(); + try { + writePersistedCommandLedger(); + } catch (error) { + args.logger.warn("sync_host.command_ledger_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const startCommandRecord = (ack: SyncCommandAckPayload): CachedMobileCommand | null => { + sendRequired(peer, "command_ack", ack, requestId); + if (!commandCacheKey) return null; + const record: CachedMobileCommand = { + commandId, + action: payload.action, + argsKey: commandArgsKey, + argsFingerprint: commandArgsFingerprint, + ack, + result: null, + waiters: [{ peer, requestId }], + acceptedAtMs: Date.now(), + completedAtMs: null, + }; + mobileCommandResultCache.set(commandCacheKey, record); + return record; + }; + const existingCommand = commandCacheKey ? mobileCommandResultCache.get(commandCacheKey) : null; + if (existingCommand) { + if (existingCommand.action !== payload.action || existingCommand.argsFingerprint !== commandArgsFingerprint) { + commandConflictCount += 1; + const mismatchResult: SyncCommandResultPayload = { + commandId, + ok: false, + error: { + code: "duplicate_command_mismatch", + message: "A command with this id already exists for a different action or payload.", + }, + }; + sendRequired(peer, "command_ack", { + commandId, + accepted: false, + status: "rejected", + message: mismatchResult.error?.message ?? null, + }, requestId); + sendRequired(peer, "command_result", mismatchResult, requestId); + return; + } + commandReplayCount += 1; + sendRequired(peer, "command_ack", existingCommand.ack, requestId); + if (existingCommand.result) { + sendRequired(peer, "command_result", existingCommand.result, requestId); + } else { + addMobileCommandWaiter(existingCommand, peer, requestId); + } + return; + } + const reject = (message: string, code = "unsupported_command") => { - send(peer.ws, "command_ack", { + const ack: SyncCommandAckPayload = { commandId, accepted: false, status: "rejected", message, - }, requestId); + }; const result: SyncCommandResultPayload = { commandId, ok: false, @@ -1644,7 +2098,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { message, }, }; - send(peer.ws, "command_result", result, requestId); + sendResult(startCommandRecord(ack), result); }; const policy = remoteCommandService.getPolicy(payload.action); @@ -1665,17 +2119,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; const existing = readNotificationPrefsForDevice(deviceId); storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); - send(peer.ws, "command_ack", { + const ack: SyncCommandAckPayload = { commandId, accepted: true, status: "accepted", message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", - }, requestId); - send(peer.ws, "command_result", { + }; + sendResult(startCommandRecord(ack), { commandId, ok: true, result: { ok: true, muteUntil }, - }, requestId); + }); return; } if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { @@ -1696,19 +2150,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { args.onStateChanged?.(); broadcastBrainStatus(); } - send(peer.ws, "command_ack", { + const ack: SyncCommandAckPayload = { commandId, accepted: true, status: "accepted", message: payload.action === "lanes.presence.announce" ? `Marked ${laneId} as open on ${marker.displayName}.` : `Released ${laneId} on ${marker.displayName}.`, - }, requestId); - send(peer.ws, "command_result", { + }; + sendResult(startCommandRecord(ack), { commandId, ok: true, result: { ok: true }, - }, requestId); + }); return; } if (!policy) { @@ -1728,29 +2182,29 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } - send(peer.ws, "command_ack", { + const acceptedRecord = startCommandRecord({ commandId, accepted: true, status: "accepted", message: `Executing ${payload.action}.`, - }, requestId); + }); try { const created = await remoteCommandService.execute(payload); - send(peer.ws, "command_result", { + sendResult(acceptedRecord, { commandId, ok: true, result: decorateCommandResult(payload.action, created), - }, requestId); + }); } catch (error) { - send(peer.ws, "command_result", { + sendResult(acceptedRecord, { commandId, ok: false, error: { code: "command_failed", message: error instanceof Error ? error.message : String(error), }, - }, requestId); + }); } } @@ -1905,6 +2359,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { projectCatalog: { enabled: Boolean(args.projectCatalogProvider), }, + changesetAck: { + enabled: true, + }, bootstrapAuth: true, pairingAuth: { enabled: true, @@ -1956,16 +2413,47 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } case "changeset_batch": { const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; + const batchId = payload.batchId || envelope.requestId || ""; const changes = Array.isArray(payload.changes) ? payload.changes as CrsqlChangeRow[] : []; - if (changes.length > 0) { - args.db.sync.applyChanges(changes); - peer.lastAppliedAt = nowIso(); - lastBroadcastAt = nowIso(); - args.onStateChanged?.(); - broadcastBrainStatus(); + try { + let appliedCount = 0; + if (changes.length > 0) { + args.db.sync.applyChanges(changes); + appliedCount = changes.length; + peer.lastAppliedAt = nowIso(); + lastBroadcastAt = nowIso(); + args.onStateChanged?.(); + broadcastBrainStatus(); + } + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount, + ok: true, + } satisfies SyncChangesetAckPayload, envelope.requestId); + } catch (error) { + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount: 0, + ok: false, + error: { + code: "changeset_apply_failed", + message: error instanceof Error ? error.message : String(error), + }, + } satisfies SyncChangesetAckPayload, envelope.requestId); + throw error; } break; } + case "changeset_ack": { + handleChangesetAck(peer, envelope.payload as SyncChangesetAckPayload); + break; + } case "file_request": await handleFileRequest(peer, envelope.requestId, envelope.payload as SyncFileRequest); break; @@ -1990,7 +2478,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lastOutputPreview: session?.lastOutputPreview ?? null, capturedAt: nowIso(), }; - send(peer.ws, "terminal_snapshot", snapshot, envelope.requestId); + sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); break; } case "terminal_unsubscribe": { @@ -2063,7 +2551,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { truncated: transcriptSize > maxBytes, events, }; - send(peer.ws, "chat_subscribe", snapshot, envelope.requestId); + sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); break; } case "chat_unsubscribe": { @@ -2107,7 +2595,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const deviceId = peer.metadata?.deviceId; if (!deviceId) { args.logger.warn("sync_host.push_token_missing_device", {}); - send(peer.ws, "command_ack", { + sendRequired(peer, "command_ack", { commandId: "push-token:unknown", accepted: false, status: "missing_device_id", @@ -2117,7 +2605,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { args.logger.warn("sync_host.push_token_missing", { deviceId }); - send(peer.ws, "command_ack", { + sendRequired(peer, "command_ack", { commandId: `push-token:${deviceId}:unknown`, accepted: false, status: "invalid_payload", @@ -2131,7 +2619,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : "alert"; if (kind === "activity-update" && !payload.activityId?.trim()) { args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); - send(peer.ws, "command_ack", { + sendRequired(peer, "command_ack", { commandId: `push-token:${deviceId}:${kind}`, accepted: false, status: "missing_activity_id", @@ -2145,7 +2633,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { activityId: payload.activityId, }); if (!stored) { - send(peer.ws, "command_ack", { + sendRequired(peer, "command_ack", { commandId: `push-token:${deviceId}:${kind}`, accepted: false, status: "device_not_found", @@ -2154,7 +2642,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } // Optional ack so the client can retry on failure. - send(peer.ws, "command_ack", { + sendRequired(peer, "command_ack", { commandId: `push-token:${deviceId}:${kind}`, accepted: true, status: "accepted", @@ -2179,7 +2667,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const result = args.notificationEventBus ? await args.notificationEventBus.sendTestPush(deviceId, kind) : { ok: false, reason: "notification_bus_unavailable" as const }; - send(peer.ws, "command_result", { + sendRequired(peer, "command_result", { commandId: `push-test:${deviceId}:${kind}`, ok: result.ok, ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), @@ -2442,6 +2930,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { disposed = true; localActiveLaneIds = new Set(); lanePresenceByLaneId.clear(); + dropInFlightCommandRecordsForProject(); chatEventSubscription?.(); clearInterval(pollTimer); clearInterval(heartbeatTimer); diff --git a/apps/desktop/src/main/services/sync/syncPeerService.ts b/apps/desktop/src/main/services/sync/syncPeerService.ts index aaa558ed5..b67af24ba 100644 --- a/apps/desktop/src/main/services/sync/syncPeerService.ts +++ b/apps/desktop/src/main/services/sync/syncPeerService.ts @@ -1,6 +1,7 @@ import { WebSocket, type RawData } from "ws"; import type { SyncBrainStatusPayload, + SyncChangesetAckPayload, SyncChangesetBatchPayload, SyncClientStatus, SyncCommandAckPayload, @@ -32,6 +33,15 @@ type PendingRequest = { }; type InternalStatus = SyncClientStatus; +type PendingChangesetBatch = { + batchId: string; + payload: SyncChangesetBatchPayload; + sentAtMs: number; + retryCount: number; +}; + +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; export function createSyncPeerService(args: SyncPeerServiceArgs) { let ws: WebSocket | null = null; @@ -40,9 +50,9 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { let heartbeatTimer: NodeJS.Timeout | null = null; let connectionDraft: SyncDesktopConnectionDraft | null = null; let latestBrainStatus: SyncBrainStatusPayload | null = null; - let latestBrainMetadata: SyncPeerMetadata | null = null; let outboundLocalDbVersion = args.db.sync.getDbVersion(); let latestRemoteDbVersion = 0; + let pendingOutboundChangeset: PendingChangesetBatch | null = null; const pendingRequests = new Map(); let pendingConnect: { resolve: () => void; reject: (error: Error) => void } | null = null; @@ -118,31 +128,96 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { deviceType: localDevice.deviceType, siteId: localDevice.siteId, dbVersion: latestRemoteDbVersion, + capabilities: ["changesetAck"], + }; + }; + + const sendChangesetAck = ( + batch: SyncChangesetBatchPayload, + ok: boolean, + appliedDbVersion: number, + appliedCount: number, + error?: unknown, + ) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const payload: SyncChangesetAckPayload = { + batchId: batch.batchId, + fromDbVersion: Number(batch.fromDbVersion ?? 0), + toDbVersion: Number(batch.toDbVersion ?? 0), + appliedDbVersion, + appliedCount, + ok, + ...(error + ? { error: { code: "changeset_apply_failed", message: error instanceof Error ? error.message : String(error) } } + : {}), }; + ws.send( + encodeSyncEnvelope({ + type: "changeset_ack", + requestId: batch.batchId, + payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + }; + + const sendOutboundChangeset = (pending: PendingChangesetBatch) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send( + encodeSyncEnvelope({ + type: "changeset_batch", + requestId: pending.batchId, + payload: pending.payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + return true; }; const sendLocalChanges = () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; + const nowMs = Date.now(); + if (pendingOutboundChangeset) { + if (nowMs - pendingOutboundChangeset.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_peer.changeset_ack_timeout_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + }); + disconnectInternal("error", null, "Changeset acknowledgement timed out."); + return; + } + pendingOutboundChangeset.sentAtMs = nowMs; + pendingOutboundChangeset.retryCount += 1; + sendOutboundChangeset(pendingOutboundChangeset); + } + return; + } const currentDbVersion = args.db.sync.getDbVersion(); if (currentDbVersion <= outboundLocalDbVersion) return; const localSiteId = args.deviceRegistryService.getLocalSiteId(); const changes = args.db.sync .exportChangesSince(outboundLocalDbVersion) .filter((change) => change.site_id === localSiteId); - outboundLocalDbVersion = currentDbVersion; - if (!changes.length) return; - ws.send( - encodeSyncEnvelope({ - type: "changeset_batch", - payload: { - reason: "relay", - fromDbVersion: latestRemoteDbVersion, - toDbVersion: latestRemoteDbVersion, - changes, - }, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); + const previousDbVersion = outboundLocalDbVersion; + if (!changes.length) { + outboundLocalDbVersion = currentDbVersion; + return; + } + const batchId = `changeset:${currentLocalPeerMetadata().deviceId}:${previousDbVersion}:${currentDbVersion}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + pendingOutboundChangeset = { + batchId, + payload: { + batchId, + reason: "relay", + fromDbVersion: previousDbVersion, + toDbVersion: currentDbVersion, + changes, + }, + sentAtMs: nowMs, + retryCount: 0, + }; + sendOutboundChangeset(pendingOutboundChangeset); }; const startRelay = () => { @@ -185,8 +260,8 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { } } ws = null; + pendingOutboundChangeset = null; latestBrainStatus = null; - latestBrainMetadata = null; status.state = state; status.connectedAt = null; status.lastSeenAt = null; @@ -209,7 +284,6 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { brain: SyncPeerMetadata; serverDbVersion: number; }; - latestBrainMetadata = payload.brain; latestRemoteDbVersion = Math.max(0, Math.floor(payload.serverDbVersion ?? 0)); status.state = "connected"; status.connectedAt = nowIso(); @@ -220,7 +294,7 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { if (connectionDraft) { connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; } - outboundLocalDbVersion = args.db.sync.getDbVersion(); + outboundLocalDbVersion = Math.min(outboundLocalDbVersion, args.db.sync.getDbVersion()); emitStatus(); startRelay(); startHeartbeatFallback(); @@ -238,19 +312,61 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { case "changeset_batch": { const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; const changes = Array.isArray(payload.changes) ? payload.changes : []; - if (changes.length) { - args.db.sync.applyChanges(changes); - args.onRemoteChangesApplied?.(); + try { + if (changes.length) { + args.db.sync.applyChanges(changes); + args.onRemoteChangesApplied?.(); + } + latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); + if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + sendChangesetAck(payload, true, args.db.sync.getDbVersion(), changes.length); + emitStatus(); + } catch (error) { + sendChangesetAck(payload, false, args.db.sync.getDbVersion(), 0, error); + throw error; + } + break; + } + case "changeset_ack": { + const payload = envelope.payload as SyncChangesetAckPayload; + if (!pendingOutboundChangeset || payload.batchId !== pendingOutboundChangeset.batchId) break; + if (!payload.ok) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + const message = payload.error?.message ?? "Changeset apply failed repeatedly."; + args.logger.warn("sync_peer.changeset_ack_failed_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + error: message, + }); + disconnectInternal("error", null, message); + break; + } + pendingOutboundChangeset.sentAtMs = Date.now(); + pendingOutboundChangeset.retryCount += 1; + args.logger.warn("sync_peer.changeset_ack_failed", { + batchId: pendingOutboundChangeset.batchId, + error: payload.error?.message ?? "Changeset apply failed.", + }); + break; + } + if (payload.toDbVersion < pendingOutboundChangeset.payload.toDbVersion) break; + const acknowledgedRemoteVersion = Math.max( + latestRemoteDbVersion, + pendingOutboundChangeset.payload.toDbVersion, + Math.floor(payload.toDbVersion ?? 0), + ); + latestRemoteDbVersion = acknowledgedRemoteVersion; + if (connectionDraft) { + connectionDraft.lastRemoteDbVersion = acknowledgedRemoteVersion; } - latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); - if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + outboundLocalDbVersion = Math.max(outboundLocalDbVersion, pendingOutboundChangeset.payload.toDbVersion); + pendingOutboundChangeset = null; emitStatus(); break; } case "brain_status": { const payload = envelope.payload as SyncBrainStatusPayload; latestBrainStatus = payload; - latestBrainMetadata = payload.brain; status.brainDeviceId = payload.brain.deviceId; status.hostName = payload.brain.deviceName; const localDeviceId = args.deviceRegistryService.getLocalDeviceId(); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 3b603f6e0..389145b26 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -316,9 +316,16 @@ describe("resolveTokenPrice", () => { expect(price.input).toBe(2 / 1_000_000); }); - it("returns codex-mini pricing for codex-mini models", () => { - const price = resolveTokenPrice("codex-mini-latest"); + it("returns mini pricing for mini OpenAI models", () => { + const price = resolveTokenPrice("gpt-5.4-mini"); expect(price.input).toBe(0.3 / 1_000_000); + expect(price.output).toBe(1.2 / 1_000_000); + }); + + it("does not treat Gemini as a mini OpenAI model", () => { + const price = resolveTokenPrice("gemini-2.5-pro"); + expect(price.input).toBe(3 / 1_000_000); + expect(price.output).toBe(15 / 1_000_000); }); it("returns default pricing for unknown models", () => { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 9cde3b1da..dfcedf320 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -56,7 +56,7 @@ const TOKEN_PRICES: Record = { "claude-sonnet": { input: 3 / 1_000_000, output: 15 / 1_000_000 }, "claude-haiku": { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, "codex": { input: 2 / 1_000_000, output: 8 / 1_000_000 }, - "codex-mini": { input: 0.3 / 1_000_000, output: 1.2 / 1_000_000 }, + "openai-mini": { input: 0.3 / 1_000_000, output: 1.2 / 1_000_000 }, "default": { input: 3 / 1_000_000, output: 15 / 1_000_000 }, }; @@ -512,10 +512,11 @@ interface TokenEntry { function resolveTokenPrice(model: string): { input: number; output: number } { const lower = (model ?? "").toLowerCase(); + const isMiniModel = /(?:^|[\/._-])mini(?:$|[\/._-])/.test(lower); if (lower.includes("opus")) return TOKEN_PRICES["claude-opus"]!; if (lower.includes("sonnet")) return TOKEN_PRICES["claude-sonnet"]!; if (lower.includes("haiku")) return TOKEN_PRICES["claude-haiku"]!; - if (lower.includes("codex") && lower.includes("mini")) return TOKEN_PRICES["codex-mini"]!; + if (isMiniModel) return TOKEN_PRICES["openai-mini"]!; if (lower.includes("codex") || lower.includes("gpt") || lower.includes("o3") || lower.includes("o4")) return TOKEN_PRICES["codex"]!; return TOKEN_PRICES["default"]!; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index b582f210b..2bc34b6a2 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -44,7 +44,7 @@ const resolvedArg2 = async (_a: any, _b: any) => v; const DEFAULT_BROWSER_MOCK_CODEX_MODEL = - getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; + getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; const DEFAULT_BROWSER_MOCK_CLAUDE_MODEL = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; @@ -3258,7 +3258,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { sessionId: "chat-review-1", laneId: MOCK_LANES[1]?.id ?? "lane-auth", provider: "codex", - model: "GPT-5.4 Codex", + model: "GPT-5.4", modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, title: "Review: feature/auth-flow vs main", surface: "automation", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 82f7752e6..ec7cf09f0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -70,8 +70,8 @@ afterEach(() => { function buildComposerProps(overrides: Partial> = {}) { const props: ComponentProps = { - modelId: "openai/gpt-5.4-codex", - availableModelIds: ["openai/gpt-5.4-codex"], + modelId: "openai/gpt-5.4", + availableModelIds: ["openai/gpt-5.4"], reasoningEffort: null, draft: "Need a steer message", attachments: [], @@ -618,7 +618,7 @@ describe("AgentChatComposer", () => { parallelLaunchBusy: true, parallelLaunchStatus: "Creating child lanes…", parallelModelSlots: [ - { modelId: "openai/gpt-5.4-codex", reasoningEffort: "high" }, + { modelId: "openai/gpt-5.4", reasoningEffort: "high" }, { modelId: "anthropic/claude-sonnet-4-6", reasoningEffort: "medium" }, { modelId: "openai/gpt-5.4-mini", reasoningEffort: "low" }, ], diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 61b4583c4..13922ca1c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -915,7 +915,7 @@ describe("AgentChatMessageList transcript rendering", () => { type: "done", turnId: "turn-live", status: "completed", - modelId: "gpt-5.4-codex", + modelId: "gpt-5.4", }, }, ], @@ -1325,8 +1325,30 @@ describe("AgentChatMessageList transcript rendering", () => { }); describe("deriveTurnModelState", () => { + it("shows the canonical display name for legacy Codex model aliases", () => { + const state = deriveTurnModelState([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "done", + turnId: "turn-1", + status: "completed", + modelId: "openai/gpt-5.5-codex", + model: "gpt-5.5", + }, + }, + ]); + + expect(state.map.get("turn-1")?.label).toBe("GPT-5.5"); + }); + it("only processes newly appended done events when history grows", () => { const getModelByIdSpy = vi.spyOn(modelRegistry, "getModelById").mockReturnValue({ + id: "openai/gpt-5.4", + shortId: "gpt-5.4", + providerModelId: "gpt-5.4", + aliases: [], displayName: "Codex", } as any); const firstBatch: AgentChatEventEnvelope[] = [ @@ -1337,7 +1359,7 @@ describe("deriveTurnModelState", () => { type: "done", turnId: "turn-1", status: "completed", - modelId: "gpt-5.4-codex", + modelId: "gpt-5.4", }, }, ]; @@ -1356,7 +1378,7 @@ describe("deriveTurnModelState", () => { type: "done", turnId: "turn-2", status: "completed", - modelId: "gpt-5.4-codex", + modelId: "gpt-5.4", }, }, ], diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 99afea1d7..f3ab9a326 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -44,7 +44,7 @@ import type { OperatorNavigationSuggestion, TurnDiffFile, } from "../../../shared/types"; -import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; +import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { formatTime } from "../../lib/format"; import { openExternalUrl } from "../../lib/openExternal"; @@ -1237,6 +1237,15 @@ function ToolResultCard({ event }: { event: Extract alias.trim().toLowerCase() === normalized); +} + function resolveModelLabel(modelId?: string, model?: string): string | null { if (modelId) { const desc = getModelById(modelId); @@ -1244,21 +1253,18 @@ function resolveModelLabel(modelId?: string, model?: string): string | null { // When the runtime-reported model name differs from all known canonical // identifiers, show it in the parenthetical so the user sees the exact // model string the provider returned (e.g. a snapshot variant). - const normalizedModel = model?.trim().toLowerCase() ?? ""; - const isNonCanonicalModel = normalizedModel.length > 0 - && normalizedModel !== desc.id.toLowerCase() - && normalizedModel !== desc.shortId.toLowerCase() - && normalizedModel !== desc.providerModelId.toLowerCase(); + const isNonCanonicalModel = Boolean(model?.trim()) + && !isKnownModelRefForDescriptor(desc, model); if (isNonCanonicalModel) { return `${desc.displayName} (${model?.trim()})`; } - return `${desc.displayName} (${modelId})`; + return desc.displayName; } return modelId; } if (model) { const desc = resolveModelDescriptor(model); - if (desc) return `${desc.displayName} (${desc.id})`; + if (desc) return desc.displayName; return model; } return null; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index a462da570..017453868 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import type { AgentChatEventEnvelope, @@ -25,7 +25,7 @@ function buildSession(sessionId: string, overrides: Partial { expect(handoff).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: session.sessionId, targetModelId: "openai/gpt-5.4-mini", - reasoningEffort: null, + reasoningEffort: "xhigh", + permissionMode: "default", claudePermissionMode: "default", opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", codexApprovalPolicy: "on-request", codexSandbox: "workspace-write", codexConfigSource: "flags", @@ -967,7 +969,7 @@ describe("AgentChatPane submit recovery", () => { ); const trigger = await screen.findByRole("button", { name: "Select model" }); - const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; fireEvent.click(trigger); fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); @@ -1033,7 +1035,7 @@ describe("AgentChatPane submit recovery", () => { ); const trigger = await screen.findByRole("button", { name: "Select model" }); - const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; fireEvent.click(trigger); fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); @@ -1163,6 +1165,74 @@ describe("AgentChatPane submit recovery", () => { expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-2" })); }); + it("hydrates a visible inactive grid tile without requiring a click", async () => { + const session = buildSession("grid-inactive-chat", { + title: "Grid inactive chat", + }); + installAdeMocks({ sessions: [session] }); + const readTranscriptTail = vi.fn().mockResolvedValue(`${JSON.stringify({ + sessionId: session.sessionId, + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "text", + text: "Visible inactive grid tile loaded", + turnId: "turn-grid", + messageId: "assistant-grid", + }, + })}\n`); + window.ade.sessions.readTranscriptTail = readTranscriptTail as any; + + render( + + + , + ); + + expect(await screen.findByText("Visible inactive grid tile loaded")).toBeTruthy(); + expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId })); + }); + + it("does not hydrate hidden inactive chat tiles", async () => { + vi.useFakeTimers(); + const session = buildSession("hidden-inactive-chat", { + title: "Hidden inactive chat", + }); + installAdeMocks({ sessions: [session] }); + const readTranscriptTail = vi.fn().mockResolvedValue(""); + window.ade.sessions.readTranscriptTail = readTranscriptTail as any; + + try { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(550); + }); + expect(readTranscriptTail).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("shows 'New chat' in the header when no session is selected", async () => { installAdeMocks({ sessions: [] }); @@ -1272,13 +1342,13 @@ describe("AgentChatPane submit recovery", () => { renderParallelDraftPane({ availableModelIdsOverride: [ - "openai/gpt-5.4-codex", + "openai/gpt-5.4", "anthropic/claude-sonnet-4-6", ], }); const baseModelTrigger = await screen.findByRole("button", { name: "Select model" }); - const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; fireEvent.click(baseModelTrigger); fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); @@ -1299,7 +1369,7 @@ describe("AgentChatPane submit recovery", () => { expect(suggestLaneName).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-1", prompt: "Fix the login bug", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", })); expect(createChild).toHaveBeenCalledTimes(2); }); @@ -1315,7 +1385,7 @@ describe("AgentChatPane submit recovery", () => { expect(create).toHaveBeenNthCalledWith(1, expect.objectContaining({ laneId: "lane-child-1", provider: "codex", - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", })); expect(create).toHaveBeenNthCalledWith(2, expect.objectContaining({ laneId: "lane-child-2", @@ -1437,13 +1507,13 @@ describe("AgentChatPane submit recovery", () => { renderParallelDraftPane({ availableModelIdsOverride: [ - "openai/gpt-5.4-codex", + "openai/gpt-5.4", "anthropic/claude-sonnet-4-6", ], }); const baseModelTrigger = await screen.findByRole("button", { name: "Select model" }); - const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; fireEvent.click(baseModelTrigger); fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts index 18fdb56ea..61b4747a5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts @@ -124,8 +124,8 @@ describe("mergeChatHistorySnapshot", () => { describe("parallel launch helpers", () => { it("keeps same-family model lane suffixes distinct", () => { - expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4-codex"))).toBe("codex-gpt-5-4"); - expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4-mini-codex"))).toBe("codex-gpt-5-4-mini"); + expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4"))).toBe("codex-gpt-5-4"); + expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4-mini"))).toBe("codex-gpt-5-4-mini"); }); it("preserves the default attachment review request when project docs are prepended", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c06334f94..ce74fe26e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -901,6 +901,14 @@ function trimChatEventHistory(events: AgentChatEventEnvelope[], maxEvents: numbe return events.length > maxEvents ? events.slice(-maxEvents) : events; } +function stableSessionDelayOffset(sessionId: string): number { + let hash = 0; + for (let index = 0; index < sessionId.length; index += 1) { + hash = ((hash * 31) + sessionId.charCodeAt(index)) >>> 0; + } + return hash; +} + function chatEventDedupKey(entry: AgentChatEventEnvelope): string { return `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; } @@ -1320,6 +1328,7 @@ export function AgentChatPane({ embeddedWorkLayout = false, layoutVariant = "standard", isTileActive = true, + isTileVisible = isTileActive, shouldAutofocusComposer = false, onSessionCreated, availableLanes, @@ -1343,6 +1352,8 @@ export function AgentChatPane({ embeddedWorkLayout?: boolean; layoutVariant?: "standard" | "grid-tile"; isTileActive?: boolean; + /** Visible grid tiles hydrate transcripts even when they are not the focused tile. */ + isTileVisible?: boolean; shouldAutofocusComposer?: boolean; onSessionCreated?: (session: AgentChatSession) => void | Promise; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ @@ -2990,7 +3001,7 @@ export function AgentChatPane({ }, [handoffOpen, handoffModelId, handoffTargetDescriptor]); useEffect(() => { - if (!isTileActive) return; + if (!isTileVisible) return; if (!selectedSessionId) return; if (!lockedSingleSessionMode) { // Re-read the selected transcript on every tab switch so the selected @@ -3004,11 +3015,14 @@ export function AgentChatPane({ // switch, session tile activation) we always pull the freshest snapshot // rather than short-circuiting on a stale loadedHistoryRef from the // previous component instance. + const hydrateDelayMs = isTileActive + ? 120 + : 220 + (stableSessionDelayOffset(selectedSessionId) % 260); const handle = window.setTimeout(() => { void loadHistory(selectedSessionId, { force: true }); - }, 120); + }, hydrateDelayMs); return () => window.clearTimeout(handle); - }, [isTileActive, loadHistory, lockedSingleSessionMode, selectedSessionId]); + }, [isTileActive, isTileVisible, loadHistory, lockedSingleSessionMode, selectedSessionId]); useEffect(() => { if (!isTileActive) { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index f70126018..35b408f64 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -773,7 +773,7 @@ describe("deriveTurnDividerData", () => { type: "done", turnId: "turn-1", status: "completed", - modelId: "gpt-5.4-codex", + modelId: "gpt-5.4", model: "GPT-5.4", usage: { inputTokens: 100, @@ -795,7 +795,7 @@ describe("deriveTurnDividerData", () => { expect(turn.deletions).toBe(1); expect(turn.status).toBe("completed"); expect(turn.model).toBe("GPT-5.4"); - expect(turn.modelId).toBe("gpt-5.4-codex"); + expect(turn.modelId).toBe("gpt-5.4"); expect(turn.inputTokens).toBe(100); expect(turn.outputTokens).toBe(50); expect(turn.cacheReadTokens).toBe(10); diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx index be971be2b..b9de14c1c 100644 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx +++ b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx @@ -75,7 +75,7 @@ const DECISION_TIMEOUT_CAP_OPTIONS: OrchestratorDecisionTimeoutCapHours[] = [6, const DEFAULT_ORCHESTRATOR_MODEL_BY_PROVIDER: Record<"claude" | "codex", MissionModelConfig["orchestratorModel"]> = { claude: { provider: "claude", modelId: getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - codex: { provider: "codex", modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex", thinkingLevel: "medium" }, + codex: { provider: "codex", modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5", thinkingLevel: "medium" }, }; const HIGH_TEAMMATE_COUNT_GUARDRAIL_THRESHOLD = 5; diff --git a/apps/desktop/src/renderer/components/missions/missionHelpers.ts b/apps/desktop/src/renderer/components/missions/missionHelpers.ts index 3f18ff3f0..511075f91 100644 --- a/apps/desktop/src/renderer/components/missions/missionHelpers.ts +++ b/apps/desktop/src/renderer/components/missions/missionHelpers.ts @@ -249,7 +249,7 @@ export function plannerProviderToModelConfig(provider: PlannerProvider): ModelCo if (provider === "codex") { return { provider: "codex", - modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex", + modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5", thinkingLevel: "medium", }; } diff --git a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts b/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts index d145c6093..61200875b 100644 --- a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts +++ b/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts @@ -2,7 +2,7 @@ import type { ModelConfig, PhaseCard, PhaseProfile } from "../../../shared/types import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; +const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; const DEFAULT_MODELS: Record = { planning: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 21c18dfa3..bce667dec 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -338,7 +338,7 @@ function renderPane(args: { saveConvergenceState, resetConvergenceState, rebaseNeeds: [], - resolverModel: "openai/gpt-5.4-codex", + resolverModel: "openai/gpt-5.4", resolverReasoningLevel: "high", resolverPermissionMode: "guarded_edit", setResolverModel: vi.fn(), @@ -452,7 +452,7 @@ describe("PrDetailPane issue resolver CTA", () => { saveConvergenceState: vi.fn(), resetConvergenceState: vi.fn(), rebaseNeeds: [], - resolverModel: "openai/gpt-5.4-codex", + resolverModel: "openai/gpt-5.4", resolverReasoningLevel: "high", resolverPermissionMode: "guarded_edit", setResolverModel: vi.fn(), diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx index a1d0355fe..ee697fde7 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx @@ -77,7 +77,7 @@ function renderPanel(overrides: Partial = {}) { items: [], convergence: makeConvergence(), checks: [], - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.4", reasoningEffort: "high", permissionMode: "guarded_edit" as AiPermissionMode, busy: false, diff --git a/apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.test.tsx index 45caff7e0..1d35daed6 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.test.tsx @@ -67,7 +67,7 @@ function renderModal( availability={availability} checks={failingChecks} reviewThreads={reviewThreads} - modelId="openai/gpt-5.4-codex" + modelId="openai/gpt-5.4" reasoningEffort="high" permissionMode="guarded_edit" busy={false} diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx index 5ba304c18..5ec57a0fe 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -84,7 +84,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -114,7 +114,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "lane", laneId: "lane-review" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", reasoningEffort: "high", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -144,7 +144,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -170,7 +170,7 @@ describe("ReviewPage", () => { sessionId: "session-1", laneId: "lane-review", provider: "codex", - model: "gpt-5.4-codex", + model: "gpt-5.4", status: "active", startedAt: "2026-04-02T12:01:00.000Z", endedAt: null, @@ -288,7 +288,7 @@ describe("ReviewPage", () => { sessionId: "session-2", laneId: "lane-bugfix", provider: "codex", - model: "gpt-5.4-codex", + model: "gpt-5.4", status: "active", startedAt: "2026-04-03T12:01:00.000Z", endedAt: null, @@ -324,7 +324,7 @@ describe("ReviewPage", () => { { sha: "def456abc1237890", shortSha: "def456a", subject: "Second commit", authoredAt: "2026-04-02T12:00:00.000Z", pushed: true }, ], }, - recommendedModelId: "openai/gpt-5.5-codex", + recommendedModelId: "openai/gpt-5.5", })), listRuns: vi.fn(async () => runs), getRunDetail: vi.fn(async (runId: string) => details.get(runId) ?? null), @@ -437,7 +437,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", reasoningEffort: "medium", publishBehavior: "local_only", }); @@ -474,7 +474,7 @@ describe("ReviewPage", () => { expect(config).toMatchObject({ selectionMode: "selected_commits", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", }); }); @@ -490,7 +490,7 @@ describe("ReviewPage", () => { { sha: "abc123def4567890", shortSha: "abc123d", subject: "Only commit", authoredAt: "2026-04-01T12:00:00.000Z", pushed: true }, ], }, - recommendedModelId: "openai/gpt-5.5-codex", + recommendedModelId: "openai/gpt-5.5", }); render( @@ -527,7 +527,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.5-codex", + modelId: "openai/gpt-5.5", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index c84646edf..b1368e4f1 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -75,7 +75,7 @@ type LaunchDraft = { maxPublishedFindings: number; }; -const DEFAULT_REVIEW_LAUNCH_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_REVIEW_LAUNCH_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4"; const DEFAULT_REVIEW_REASONING_EFFORT = "medium"; type NormalizedRun = Omit & { diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx index 78d5f0bb9..21d0b212b 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx @@ -99,7 +99,7 @@ const PREVIEW_USAGE_MODEL: Record< PreviewProviderKey, { Logo: React.ComponentType<{ size?: number; className?: string }>; label: string } > = { - codex: { Logo: CodexLogo, label: "gpt-5.5-codex" }, + codex: { Logo: CodexLogo, label: "gpt-5.5" }, claude: { Logo: ClaudeLogo, label: "Claude Opus 4.7" }, opencode: { Logo: OpenCodeLogo, label: "local · runtime" }, }; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index b6c6faca4..3789e506f 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -331,12 +331,13 @@ describe("TerminalView", () => { it("fits to the container and resizes the PTY when the fit result is valid", async () => { vi.useRealTimers(); try { + window.localStorage.setItem("ade.terminalRenderer", "webgl"); render(); await waitFor( () => { const runtime = getTerminalRuntimeSnapshot("session-valid"); - expect(runtime?.renderer).toBe("dom"); + expect(runtime?.renderer).toBe("webgl"); expect(runtime?.health.fitRecoveries).toBe(0); expect((window as any).ade.pty.resize).toHaveBeenCalledWith({ ptyId: "pty-valid", @@ -351,16 +352,16 @@ describe("TerminalView", () => { } }); - it("uses the WebGL renderer when explicitly opted in", async () => { + it("uses the DOM renderer when explicitly opted out", async () => { vi.useRealTimers(); try { - window.localStorage.setItem("ade.terminalRenderer", "webgl"); - render(); + window.localStorage.setItem("ade.terminalRenderer", "dom"); + render(); await waitFor( () => { - const runtime = getTerminalRuntimeSnapshot("session-webgl"); - expect(runtime?.renderer).toBe("webgl"); + const runtime = getTerminalRuntimeSnapshot("session-dom-opt-out"); + expect(runtime?.renderer).toBe("dom"); }, { timeout: 10_000 }, ); @@ -369,6 +370,41 @@ describe("TerminalView", () => { } }); + it("uses the DOM renderer on Linux when localStorage is unavailable", async () => { + vi.useRealTimers(); + const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + const originalPlatform = window.navigator.platform; + const getItemSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage unavailable"); + }); + try { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "Linux x86_64", + }); + render(); + + await waitFor( + () => { + const runtime = getTerminalRuntimeSnapshot("session-linux-storage"); + expect(runtime?.renderer).toBe("dom"); + }, + { timeout: 10_000 }, + ); + } finally { + getItemSpy.mockRestore(); + if (platformDescriptor) { + Object.defineProperty(window.navigator, "platform", platformDescriptor); + } else { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + } + vi.useFakeTimers(); + } + }); + it("rejects implausible fit results, restores the last good size, and skips PTY resize", async () => { render(); await flushAllTimers(); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 86f7617c6..da36b3fbd 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -121,11 +121,18 @@ function isDarkTheme(theme: ThemeId): boolean { } function terminalWebglRendererEnabled(): boolean { + let stored: string | null = null; try { - return window.localStorage.getItem(TERMINAL_RENDERER_STORAGE_KEY) === "webgl"; + stored = window.localStorage.getItem(TERMINAL_RENDERER_STORAGE_KEY); } catch { - return false; + // Storage can be unavailable in hardened/browser-like environments; still + // honor the Linux renderer fallback below. } + if (stored != null) return stored !== "dom"; + const platform = window.navigator?.platform?.toLowerCase() ?? ""; + const userAgent = window.navigator?.userAgent?.toLowerCase() ?? ""; + if (platform.includes("linux") || userAgent.includes("linux")) return false; + return true; } function cloneHealth(health: TerminalHealthCounters): TerminalHealthCounters { @@ -796,7 +803,7 @@ function createRuntime(args: { const term = new Terminal({ allowProposedApi: true, convertEol: true, - cursorBlink: false, + cursorBlink: true, cursorInactiveStyle: "none", documentOverride: document, scrollback: args.preferences.scrollback, diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 1cceba781..c8aac6959 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -23,7 +23,15 @@ vi.mock("./TerminalView", () => ({ vi.mock("../chat/AgentChatPane", async () => { const React = await vi.importActual("react") as typeof ReactNamespace; return { - AgentChatPane: ({ lockSessionId }: { lockSessionId?: string | null }) => { + AgentChatPane: ({ + lockSessionId, + isTileActive, + isTileVisible, + }: { + lockSessionId?: string | null; + isTileActive?: boolean; + isTileVisible?: boolean; + }) => { const sessionId = lockSessionId ?? "draft"; React.useEffect(() => { chatPaneLifecycle.mounts.set(sessionId, (chatPaneLifecycle.mounts.get(sessionId) ?? 0) + 1); @@ -31,7 +39,14 @@ vi.mock("../chat/AgentChatPane", async () => { chatPaneLifecycle.unmounts.set(sessionId, (chatPaneLifecycle.unmounts.get(sessionId) ?? 0) + 1); }; }, [sessionId]); - return
; + return ( +
+ ); }, }; }); @@ -418,6 +433,59 @@ describe("WorkViewArea", () => { expect(chatPaneLifecycle.unmounts.get("chat-2")).toBeUndefined(); }); + it("marks every chat tile visible in grid mode while only the selected tile is active", () => { + vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); + const first = makeChatSession("chat-1"); + const second = makeChatSession("chat-2"); + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + const tiles = within(view.container).getAllByTestId("agent-chat-pane"); + const firstTile = tiles.find((el) => el.getAttribute("data-session-id") === "chat-1"); + const secondTile = tiles.find((el) => el.getAttribute("data-session-id") === "chat-2"); + expect(firstTile?.getAttribute("data-tile-active")).toBe("true"); + expect(firstTile?.getAttribute("data-tile-visible")).toBe("true"); + expect(secondTile?.getAttribute("data-tile-active")).toBe("false"); + expect(secondTile?.getAttribute("data-tile-visible")).toBe("true"); + }); + it("keeps open chat tabs mounted while switching the active tab", () => { vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); const first = makeChatSession("chat-1"); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 6b53c38f9..bd5773895 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -75,6 +75,7 @@ function SessionSurface({ onSessionCreated={onOpenChatSession} layoutVariant={layoutVariant} isTileActive={isActive} + isTileVisible={layoutVariant === "grid-tile" ? true : isActive} shouldAutofocusComposer={shouldAutofocus} /> ); diff --git a/apps/desktop/src/shared/chatModelSwitching.test.ts b/apps/desktop/src/shared/chatModelSwitching.test.ts index 005414b43..01c43f5bf 100644 --- a/apps/desktop/src/shared/chatModelSwitching.test.ts +++ b/apps/desktop/src/shared/chatModelSwitching.test.ts @@ -7,16 +7,16 @@ describe("chatModelSwitching", () => { filterChatModelIdsForSession({ availableModelIds: [ "anthropic/claude-sonnet-4-6", - "openai/gpt-5.4-codex", - "openai/gpt-5.2-codex", + "openai/gpt-5.4", + "openai/gpt-5.3-codex", ], activeSessionModelId: "anthropic/claude-sonnet-4-6", hasConversation: true, }), ).toEqual([ "anthropic/claude-sonnet-4-6", - "openai/gpt-5.4-codex", - "openai/gpt-5.2-codex", + "openai/gpt-5.4", + "openai/gpt-5.3-codex", ]); }); @@ -25,7 +25,7 @@ describe("chatModelSwitching", () => { filterChatModelIdsForSession({ availableModelIds: [ "anthropic/claude-sonnet-4-6", - "openai/gpt-5.4-codex", + "openai/gpt-5.4", "openai/gpt-5.2", ], activeSessionModelId: "anthropic/claude-sonnet-4-6", @@ -34,7 +34,7 @@ describe("chatModelSwitching", () => { }), ).toEqual([ "anthropic/claude-sonnet-4-6", - "openai/gpt-5.4-codex", + "openai/gpt-5.4", "openai/gpt-5.2", ]); }); @@ -42,7 +42,7 @@ describe("chatModelSwitching", () => { it("allows same-family switches after launch", () => { expect( canSwitchChatSessionModel({ - currentModelId: "openai/gpt-5.4-codex", + currentModelId: "openai/gpt-5.4", nextModelId: "openai/gpt-5.2", hasConversation: true, }), @@ -53,7 +53,7 @@ describe("chatModelSwitching", () => { expect( canSwitchChatSessionModel({ currentModelId: "anthropic/claude-sonnet-4-6", - nextModelId: "openai/gpt-5.4-codex", + nextModelId: "openai/gpt-5.4", hasConversation: true, }), ).toBe(true); @@ -61,7 +61,7 @@ describe("chatModelSwitching", () => { expect( canSwitchChatSessionModel({ currentModelId: "anthropic/claude-sonnet-4-6", - nextModelId: "openai/gpt-5.4-codex", + nextModelId: "openai/gpt-5.4", hasConversation: true, policy: "any-after-launch", }), @@ -72,7 +72,7 @@ describe("chatModelSwitching", () => { expect( canSwitchChatSessionModel({ currentModelId: "anthropic/claude-sonnet-4-6", - nextModelId: "openai/gpt-5.4-codex", + nextModelId: "openai/gpt-5.4", hasConversation: false, }), ).toBe(true); diff --git a/apps/desktop/src/shared/modelProfiles.test.ts b/apps/desktop/src/shared/modelProfiles.test.ts index b905b2454..959776c08 100644 --- a/apps/desktop/src/shared/modelProfiles.test.ts +++ b/apps/desktop/src/shared/modelProfiles.test.ts @@ -242,7 +242,7 @@ describe("getProfileById", () => { describe("resolveCallTypeModel", () => { it("returns explicit config when present in intelligenceConfig", () => { - const config = { provider: "codex" as const, modelId: "openai/gpt-5.4-codex", thinkingLevel: "high" as const }; + const config = { provider: "codex" as const, modelId: "openai/gpt-5.4", thinkingLevel: "high" as const }; const result = resolveCallTypeModel("coordinator", { coordinator: config }); expect(result).toBe(config); }); @@ -254,13 +254,13 @@ describe("resolveCallTypeModel", () => { }); it("falls back to fallbackModel when intelligenceConfig is null", () => { - const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4-codex" }; + const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4" }; const result = resolveCallTypeModel("chat_response", null, fallback); expect(result).toBe(fallback); }); it("falls back to fallbackModel when intelligenceConfig is undefined", () => { - const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4-codex" }; + const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4" }; const result = resolveCallTypeModel("coordinator", undefined, fallback); expect(result).toBe(fallback); }); diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts index f04126736..31829c95e 100644 --- a/apps/desktop/src/shared/modelProfiles.ts +++ b/apps/desktop/src/shared/modelProfiles.ts @@ -11,6 +11,7 @@ import { MODEL_REGISTRY, getModelPricing, listModelDescriptorsForProvider, + resolveModelDescriptor, updateModelPricingInRegistry, type ModelDescriptor, } from "./modelRegistry"; @@ -46,7 +47,7 @@ function descriptorToEntry(d: ModelDescriptor, overrides?: { recommended?: boole } const DEFAULT_CLAUDE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; +const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; // CLI-wrapped Anthropic models (claude provider) export const CLAUDE_MODELS: ModelEntry[] = MODEL_REGISTRY @@ -66,7 +67,9 @@ export const ALL_MODELS: ModelEntry[] = MODEL_REGISTRY .map((m) => descriptorToEntry(m)); export function findModel(modelId: string): ModelEntry | undefined { - return ALL_MODELS.find((m) => m.modelId === modelId); + const descriptor = resolveModelDescriptor(modelId); + const resolvedModelId = descriptor?.id ?? modelId; + return ALL_MODELS.find((m) => m.modelId === resolvedModelId); } export function getModelsForProvider(provider: ModelProvider): ModelEntry[] { @@ -137,7 +140,7 @@ const CLAUDE_SONNET: ModelConfig = { provider: "claude", modelId: "anthropic/cla const CLAUDE_HAIKU: ModelConfig = { provider: "claude", modelId: "anthropic/claude-haiku-4-5", thinkingLevel: "low" }; const CLAUDE_OPUS: ModelConfig = { provider: "claude", modelId: "anthropic/claude-opus-4-7", thinkingLevel: "high" }; const CODEX_STANDARD: ModelConfig = { provider: "codex", modelId: DEFAULT_CODEX_MODEL_ID, thinkingLevel: "medium" }; -const CODEX_MINI: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.1-codex-mini", thinkingLevel: "low" }; +const CODEX_MINI: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.4-mini", thinkingLevel: "low" }; export const BUILT_IN_PROFILES: MissionModelProfile[] = [ { @@ -196,7 +199,7 @@ export const BUILT_IN_PROFILES: MissionModelProfile[] = [ decisionTimeoutCapHours: 24, phaseDefaults: { planning: CLAUDE_OPUS, - implementation: { provider: "codex", modelId: "openai/gpt-5.1-codex-max", thinkingLevel: "high" }, + implementation: { provider: "codex", modelId: DEFAULT_CODEX_MODEL_ID, thinkingLevel: "high" }, testing: CODEX_STANDARD, validation: CLAUDE_OPUS, codeReview: CLAUDE_OPUS, diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index 525ca3bea..a7d25e1c2 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -51,10 +51,10 @@ describe("modelRegistry", () => { it("resolveModelSlug returns canonical id for registry input and codex-hinted refs", () => { const byId = resolveModelSlug(" anthropic/claude-opus-4-7 "); expect(byId).toBe("anthropic/claude-opus-4-7"); - expect(resolveModelSlug("gpt-5.4")).toBeUndefined(); - expect(resolveModelSlug("gpt-5.5")).toBeUndefined(); - expect(resolveModelSlug("gpt-5.4", "codex")).toBe("openai/gpt-5.4-codex"); - expect(resolveModelSlug("gpt-5.5", "codex")).toBe("openai/gpt-5.5-codex"); + expect(resolveModelSlug("gpt-5.4")).toBe("openai/gpt-5.4"); + expect(resolveModelSlug("gpt-5.5")).toBe("openai/gpt-5.5"); + expect(resolveModelSlug("gpt-5.4", "codex")).toBe("openai/gpt-5.4"); + expect(resolveModelSlug("gpt-5.5", "codex")).toBe("openai/gpt-5.5"); expect(resolveModelSlug("")).toBeUndefined(); expect(resolveModelSlug(" ")).toBeUndefined(); expect(resolveModelSlug("not-a-real-model-xyz")).toBeUndefined(); @@ -81,24 +81,20 @@ describe("modelRegistry", () => { it("keeps only the allowed OpenAI chat models in the registry defaults", () => { expect(listModelDescriptorsForProvider("codex").map((model) => model.id)).toEqual([ - "openai/gpt-5.5-codex", - "openai/gpt-5.4-codex", - "openai/gpt-5.4-mini-codex", + "openai/gpt-5.5", + "openai/gpt-5.4", + "openai/gpt-5.4-mini", "openai/gpt-5.3-codex", - "openai/gpt-5.3-codex-spark", - "openai/gpt-5.2-codex", - "openai/gpt-5.1-codex-max", - "openai/gpt-5.1-codex-mini", ]); // API-key OpenAI models are now discovered dynamically through OpenCode, // so the static registry yields no hits for api-key auth alone. expect(getAvailableModels([{ type: "api-key", provider: "openai" }]).map((model) => model.id)).toEqual([]); - expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.5-codex"); + expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.5"); }); - it("exposes GPT-5.5-Codex with the Codex app-server model id and expected reasoning tiers", () => { - expect(getModelById("openai/gpt-5.5-codex")).toMatchObject({ + it("exposes GPT-5.5 with the real OpenAI model id and expected reasoning tiers", () => { + expect(getModelById("openai/gpt-5.5")).toMatchObject({ displayName: "GPT-5.5", providerRoute: "codex-cli", providerModelId: "gpt-5.5", @@ -106,15 +102,15 @@ describe("modelRegistry", () => { }); }); - it("exposes GPT-5.4-Mini-Codex with the expected reasoning tiers", () => { - expect(getModelById("openai/gpt-5.4-mini-codex")).toMatchObject({ + it("exposes GPT-5.4-Mini with the expected reasoning tiers", () => { + expect(getModelById("openai/gpt-5.4-mini")).toMatchObject({ displayName: "GPT-5.4-Mini", reasoningTiers: ["low", "medium", "high", "xhigh"], }); }); it("marks CLI-wrapped models as CLI subscription in the shared model source helper", () => { - expect(describeModelSource(getModelById("openai/gpt-5.5-codex")!)).toBe("CLI subscription"); + expect(describeModelSource(getModelById("openai/gpt-5.5")!)).toBe("CLI subscription"); }); it("returns undefined for unknown model IDs", () => { @@ -135,40 +131,40 @@ describe("modelRegistry", () => { expect(perm?.authTypes).toContain("local"); }); - it("returns undefined for bare gpt-5.4 alias since API-key variants are now OpenCode-dynamic", () => { + it("resolves bare gpt-5.4 to the real OpenAI registry id", () => { const resolved = resolveModelAlias("gpt-5.4"); - expect(resolved).toBeUndefined(); + expect(resolved?.id).toBe("openai/gpt-5.4"); }); - it("returns undefined for bare gpt-5.5 alias since API-key variants are now OpenCode-dynamic", () => { + it("resolves bare gpt-5.5 to the real OpenAI registry id", () => { const resolved = resolveModelAlias("gpt-5.5"); - expect(resolved).toBeUndefined(); + expect(resolved?.id).toBe("openai/gpt-5.5"); }); - it("resolves gpt-5.4 to the Codex wrapper when the provider is codex", () => { + it("resolves gpt-5.4 to the real OpenAI model when the provider is codex", () => { const resolved = resolveModelDescriptorForProvider("gpt-5.4", "codex"); - expect(resolved?.id).toBe("openai/gpt-5.4-codex"); + expect(resolved?.id).toBe("openai/gpt-5.4"); }); - it("resolves gpt-5.5 to the Codex wrapper when the provider is codex", () => { + it("resolves gpt-5.5 to the real OpenAI model when the provider is codex", () => { const resolved = resolveModelDescriptorForProvider("gpt-5.5", "codex"); - expect(resolved?.id).toBe("openai/gpt-5.5-codex"); + expect(resolved?.id).toBe("openai/gpt-5.5"); }); - it("resolves gpt-5.4-codex shortId to the codex variant", () => { + it("resolves the old gpt-5.4-codex alias to the real GPT-5.4 registry id", () => { const resolved = resolveModelAlias("gpt-5.4-codex"); expect(resolved).toBeTruthy(); - expect(resolved?.id).toBe("openai/gpt-5.4-codex"); + expect(resolved?.id).toBe("openai/gpt-5.4"); }); - it("returns the real Codex runtime model name for wrapped GPT-5.4", () => { - const descriptor = getModelById("openai/gpt-5.4-codex"); + it("returns the real Codex runtime model name for GPT-5.4", () => { + const descriptor = getModelById("openai/gpt-5.4"); expect(descriptor).toBeTruthy(); expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe("gpt-5.4"); }); - it("returns the real Codex app-server runtime model name for wrapped GPT-5.5", () => { - const descriptor = getModelById("openai/gpt-5.5-codex"); + it("returns the real Codex app-server runtime model name for GPT-5.5", () => { + const descriptor = getModelById("openai/gpt-5.5"); expect(descriptor).toBeTruthy(); expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe("gpt-5.5"); }); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 8cbc95375..3e62edde3 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -193,16 +193,17 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ }, // ---- OpenAI (CLI-wrapped via codex) ---- - // ADE codex chat surfaces expose a consistent ladder: - // low | medium | high | xhigh, except GPT-5.1-Codex-Mini which only exposes medium | high. + // ADE codex chat surfaces use real OpenAI model ids as the canonical + // registry ids; older ADE-internal "-codex" wrapper ids remain aliases so + // persisted sessions continue to resolve. { - id: "openai/gpt-5.5-codex", - shortId: "gpt-5.5-codex", - aliases: ["gpt-5.5-codex"], + id: "openai/gpt-5.5", + shortId: "gpt-5.5", + aliases: ["openai/gpt-5.5-codex", "gpt-5.5-codex"], displayName: "GPT-5.5", family: "openai", authTypes: ["cli-subscription"], - contextWindow: 400_000, + contextWindow: 1_000_000, maxOutputTokens: 128_000, capabilities: ALL_CAPS, reasoningTiers: ["low", "medium", "high", "xhigh"], @@ -214,13 +215,13 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ costTier: "high", }, { - id: "openai/gpt-5.4-codex", - shortId: "gpt-5.4-codex", - aliases: ["gpt-5.4-codex"], + id: "openai/gpt-5.4", + shortId: "gpt-5.4", + aliases: ["openai/gpt-5.4-codex", "gpt-5.4-codex"], displayName: "GPT-5.4", family: "openai", authTypes: ["cli-subscription"], - contextWindow: 400_000, + contextWindow: 1_000_000, maxOutputTokens: 128_000, capabilities: ALL_CAPS, reasoningTiers: ["low", "medium", "high", "xhigh"], @@ -232,9 +233,9 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ costTier: "high", }, { - id: "openai/gpt-5.4-mini-codex", - shortId: "gpt-5.4-mini-codex", - aliases: ["gpt-5.4-mini-codex"], + id: "openai/gpt-5.4-mini", + shortId: "gpt-5.4-mini", + aliases: ["openai/gpt-5.4-mini-codex", "gpt-5.4-mini-codex"], displayName: "GPT-5.4-Mini", family: "openai", authTypes: ["cli-subscription"], @@ -270,82 +271,6 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ outputPricePer1M: 6, costTier: "high", }, - { - id: "openai/gpt-5.3-codex-spark", - shortId: "gpt-5.3-codex-spark", - displayName: "GPT-5.3-Codex-Spark", - family: "openai", - authTypes: ["cli-subscription"], - contextWindow: 192_000, - maxOutputTokens: 16_384, - capabilities: { tools: true, vision: false, reasoning: true, streaming: true }, - reasoningTiers: ["low", "medium", "high", "xhigh"], - color: "#34D399", - providerRoute: "codex-cli", - providerModelId: "gpt-5.3-codex-spark", - cliCommand: "codex", - isCliWrapped: true, - inputPricePer1M: 1, - outputPricePer1M: 4, - costTier: "medium", - }, - { - id: "openai/gpt-5.2-codex", - shortId: "gpt-5.2-codex", - displayName: "GPT-5.2-Codex", - family: "openai", - authTypes: ["cli-subscription"], - contextWindow: 400_000, - maxOutputTokens: 128_000, - capabilities: ALL_CAPS, - reasoningTiers: ["low", "medium", "high", "xhigh"], - color: "#10B981", - providerRoute: "codex-cli", - providerModelId: "gpt-5.2-codex", - cliCommand: "codex", - isCliWrapped: true, - inputPricePer1M: 1.5, - outputPricePer1M: 6, - costTier: "medium", - }, - { - id: "openai/gpt-5.1-codex-max", - shortId: "gpt-5.1-codex-max", - displayName: "GPT-5.1-Codex-Max", - family: "openai", - authTypes: ["cli-subscription"], - contextWindow: 192_000, - maxOutputTokens: 16_384, - capabilities: ALL_CAPS, - reasoningTiers: ["low", "medium", "high", "xhigh"], - color: "#10B981", - providerRoute: "codex-cli", - providerModelId: "gpt-5.1-codex-max", - cliCommand: "codex", - isCliWrapped: true, - inputPricePer1M: 3, - outputPricePer1M: 12, - costTier: "high", - }, - { - id: "openai/gpt-5.1-codex-mini", - shortId: "gpt-5.1-codex-mini", - displayName: "GPT-5.1-Codex-Mini", - family: "openai", - authTypes: ["cli-subscription"], - contextWindow: 400_000, - maxOutputTokens: 128_000, - capabilities: ALL_CAPS, - reasoningTiers: ["medium", "high"], - color: "#2DD4BF", - providerRoute: "codex-cli", - providerModelId: "gpt-5.1-codex-mini", - cliCommand: "codex", - isCliWrapped: true, - inputPricePer1M: 0.25, - outputPricePer1M: 2, - costTier: "low", - }, // ---- Cursor SDK models: discovered at runtime via @cursor/sdk (see cursorModelsDiscovery + getResolvedAvailableModels) ---- @@ -900,12 +825,8 @@ const KNOWN_DROID_COMPACT_DISPLAY_NAMES: Record = { "claude-sonnet-4-6": "Sonnet 4.6 (1.2x)", "claude-haiku-4-5-20251001": "Haiku 4.5 (0.4x)", "gpt-5.1": "GPT-5.1 (0.5x)", - "gpt-5.1-codex": "GPT-5.1-Codex (0.5x)", - "gpt-5.1-codex-max": "GPT-5.1-Codex-Max (0.5x)", "gpt-5.2": "GPT-5.2 (0.7x)", - "gpt-5.2-codex": "GPT-5.2-Codex (0.7x)", "gpt-5.3-codex": "GPT-5.3-Codex (0.7x)", - "gpt-5.3-codex-fast": "GPT-5.3-Codex Fast", "gpt-5.4": "GPT-5.4", "gpt-5.4-fast": "GPT-5.4 Fast", "gpt-5.4-mini": "GPT-5.4 Mini", diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 37d1d2454..a0bc02353 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -43,6 +43,7 @@ export type SyncPeerMetadata = { deviceType: SyncPeerDeviceType; siteId: string; dbVersion: number; + capabilities?: string[]; }; export type SyncPeerConnectionState = SyncPeerMetadata & { @@ -210,6 +211,9 @@ export type SyncFeatureFlags = { projectCatalog: { enabled: boolean; }; + changesetAck: { + enabled: boolean; + }; bootstrapAuth: true; pairingAuth: { enabled: true; @@ -340,12 +344,26 @@ export type SyncPairingResultPayload = { }; export type SyncChangesetBatchPayload = { + batchId: string; reason: "catchup" | "broadcast" | "relay"; fromDbVersion: number; toDbVersion: number; changes: CrsqlChangeRow[]; }; +export type SyncChangesetAckPayload = { + batchId: string; + fromDbVersion: number; + toDbVersion: number; + appliedDbVersion: number; + appliedCount: number; + ok: boolean; + error?: { + code: string; + message: string; + }; +}; + export type SyncHeartbeatPayload = { kind: "ping" | "pong"; sentAt: string; @@ -463,6 +481,12 @@ export type SyncBrainStatusPayload = { dbVersion: number; uptimeMs: number; lastBroadcastAt: string | null; + pendingChangesetPeerCount?: number; + commandLedgerSize?: number; + commandReplayCount?: number; + commandConflictCount?: number; + lastCommandResultLatencyMs?: number | null; + lastChangesetAckLatencyMs?: number | null; }; }; @@ -880,6 +904,7 @@ export type SyncProjectSwitchResultEnvelope = SyncEnvelopeWithPayload<"project_s export type SyncPairingRequestEnvelope = SyncEnvelopeWithPayload<"pairing_request", SyncPairingRequestPayload>; export type SyncPairingResultEnvelope = SyncEnvelopeWithPayload<"pairing_result", SyncPairingResultPayload>; export type SyncChangesetBatchEnvelope = SyncEnvelopeWithPayload<"changeset_batch", SyncChangesetBatchPayload>; +export type SyncChangesetAckEnvelope = SyncEnvelopeWithPayload<"changeset_ack", SyncChangesetAckPayload>; export type SyncHeartbeatEnvelope = SyncEnvelopeWithPayload<"heartbeat", SyncHeartbeatPayload>; export type SyncFileRequestEnvelope = SyncEnvelopeWithPayload<"file_request", SyncFileRequest>; export type SyncFileResponseEnvelope = SyncEnvelopeWithPayload<"file_response", SyncFileResponsePayload>; @@ -914,6 +939,7 @@ export type SyncEnvelope = | SyncPairingRequestEnvelope | SyncPairingResultEnvelope | SyncChangesetBatchEnvelope + | SyncChangesetAckEnvelope | SyncHeartbeatEnvelope | SyncFileRequestEnvelope | SyncFileResponseEnvelope diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 62a4ae979..db7b66837 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2800,10 +2800,63 @@ struct CrsqlChangeRow: Codable, Equatable { } struct SyncChangesetBatchPayload: Codable, Equatable { + var batchId: String var reason: String var fromDbVersion: Int var toDbVersion: Int var changes: [CrsqlChangeRow] + + init(batchId: String, reason: String, fromDbVersion: Int, toDbVersion: Int, changes: [CrsqlChangeRow]) { + self.batchId = batchId + self.reason = reason + self.fromDbVersion = fromDbVersion + self.toDbVersion = toDbVersion + self.changes = changes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + reason = try container.decode(String.self, forKey: .reason) + fromDbVersion = try container.decode(Int.self, forKey: .fromDbVersion) + toDbVersion = try container.decode(Int.self, forKey: .toDbVersion) + changes = try container.decode([CrsqlChangeRow].self, forKey: .changes) + let decodedBatchId = try container.decodeIfPresent(String.self, forKey: .batchId)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let decodedBatchId, !decodedBatchId.isEmpty { + batchId = decodedBatchId + } else { + batchId = Self.legacyBatchId(fromDbVersion: fromDbVersion, toDbVersion: toDbVersion, changes: changes) + } + } + + private static func legacyBatchId(fromDbVersion: Int, toDbVersion: Int, changes: [CrsqlChangeRow]) -> String { + guard let last = changes.last else { + return "legacy:\(fromDbVersion):\(toDbVersion):0:empty" + } + return "legacy:\(fromDbVersion):\(toDbVersion):\(changes.count):\(last.table):\(last.dbVersion):\(last.seq)" + } + + private enum CodingKeys: String, CodingKey { + case batchId + case reason + case fromDbVersion + case toDbVersion + case changes + } +} + +struct SyncChangesetAckPayload: Codable, Equatable { + struct AckError: Codable, Equatable { + var code: String + var message: String + } + + var batchId: String + var fromDbVersion: Int + var toDbVersion: Int + var appliedDbVersion: Int + var appliedCount: Int + var ok: Bool + var error: AckError? } struct ApplyRemoteChangesResult: Equatable { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b57a1265e..aff46579f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -58,6 +58,13 @@ func unwrapSyncCommandResponse(_ raw: Any) throws -> Any { ) } +func isRemoteCommandApplicationError(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == "ADE" + && nsError.code == 17 + && nsError.userInfo["ADEErrorCode"] != nil +} + func decodeHydrationPayload(_ raw: Any, as type: T.Type, domainLabel: String, decoder: JSONDecoder) throws -> T { do { let data = try adeJSONData(withJSONObject: raw) @@ -654,6 +661,13 @@ final class SyncService: ObservableObject { /// list and chat detail screens so the LA reconcile can read `modelId` /// + a real `lastActivityAt` without round-tripping for each running chat. private(set) var chatSummaryCache: [String: AgentChatSessionSummary] = [:] + private struct ChatModelsCacheEntry { + var models: [AgentChatModelInfo] + var fetchedAt: Date + } + private let chatModelsCacheTTL: TimeInterval = 300 + private var chatModelsCache: [String: ChatModelsCacheEntry] = [:] + private var chatModelsInFlight: [String: Task<[AgentChatModelInfo], Error>] = [:] private let legacyDraftKey = "ade.sync.connectionDraft" private let profileKey = "ade.sync.hostProfile" @@ -664,6 +678,10 @@ final class SyncService: ObservableObject { private let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" private let pendingOperationsKey = "ade.sync.pendingOperations" private let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + private let outboundSyncCursorsKey = "ade.sync.outboundSyncCursors" + private let pendingOutboundChangesetsKey = "ade.sync.pendingOutboundChangesets" + private let outboundSyncStateMaxEntries = 128 + private let maxChangesetAckRetries = 6 private let keychain = KeychainService() private let database: DatabaseService private let socketSessionDelegate: SyncSocketSessionDelegate @@ -681,11 +699,38 @@ final class SyncService: ObservableObject { let startedAt: TimeInterval } + private struct PendingOutboundChangeset { + var payload: SyncChangesetBatchPayload + var sentAt: TimeInterval + var retryCount: Int + } + + private struct OutboundSyncCursor: Codable, Equatable { + var projectId: String? + var projectRootPath: String? + var hostId: String? + var siteId: String + var lastAckedDbVersion: Int + var updatedAt: String + } + + private struct PersistedOutboundChangeset: Codable, Equatable { + var projectId: String? + var projectRootPath: String? + var hostId: String? + var siteId: String + var payload: SyncChangesetBatchPayload + var retryCount: Int + var updatedAt: String + } + private var pending: [String: PendingRequest] = [:] + private var pendingOutboundChangeset: PendingOutboundChangeset? private var pendingSocketOpen: [Int: CheckedContinuation] = [:] private var pendingSocketOpenTimeoutTasks: [Int: Task] = [:] private let decoder = JSONDecoder() private let encoder = JSONEncoder() + private let syncDateFormatter = ISO8601DateFormatter() private let compressionThresholdBytes = 4 * 1024 private var relayTask: Task? private var hydrationTask: Task? @@ -723,6 +768,7 @@ final class SyncService: ObservableObject { private var pendingProjectCatalogChunks: [String: [Int: [MobileProjectSummary]]] = [:] private var supportsProjectCatalog = false private var supportsChatStreaming = false + private var supportsChangesetAck = false private var projectSelectionTask: Task? private var projectSelectionGeneration: UInt64 = 0 private var healthyConnectionSampleCount = 0 @@ -1086,6 +1132,7 @@ final class SyncService: ObservableObject { setActiveProjectId(targetProject.id, rootPath: targetProject.rootPath ?? project.rootPath) refreshProjectCatalog() latestRemoteDbVersion = 0 + resetOutboundCursorStateForActiveProject() guard let connection = result.connection else { // Desktop's success path for project_switch_request intentionally returns @@ -1180,6 +1227,7 @@ final class SyncService: ObservableObject { } setActiveProjectId(previousActiveProjectId, rootPath: previousActiveProjectRootPath) latestRemoteDbVersion = previousLatestRemoteDbVersion + resetOutboundCursorStateForActiveProject() remoteProjectCatalog = previousRemoteProjectCatalog if let previousToken { keychain.saveToken(previousToken) @@ -1216,13 +1264,21 @@ final class SyncService: ObservableObject { } private func setActiveProjectId(_ projectId: String?, rootPath: String? = nil) { - activeProjectId = projectId - activeProjectRootPath = projectId == nil + let previousProjectId = normalizedProjectId(activeProjectId) + let previousRootPath = normalizedProjectRoot(activeProjectRootPath) + let nextProjectId = normalizedProjectId(projectId) + let nextRootPath = projectId == nil ? nil : normalizedProjectRoot(rootPath) ?? projectId.flatMap { id in projects.first { $0.id == id }.flatMap { normalizedProjectRoot($0.rootPath) } } + let scopeChanged = previousProjectId != nextProjectId || previousRootPath != nextRootPath + if scopeChanged { + prepareOutboundStateForProjectScopeChange() + } + activeProjectId = projectId + activeProjectRootPath = nextRootPath database.setActiveProjectId(projectId) if let projectId { UserDefaults.standard.set(projectId, forKey: activeProjectIdKey) @@ -1234,6 +1290,9 @@ final class SyncService: ObservableObject { } else { UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) } + if scopeChanged { + resetOutboundCursorStateForActiveProject() + } } private func normalizeActiveProjectSelection(allowSingleProjectFallback: Bool) { @@ -1288,6 +1347,9 @@ final class SyncService: ObservableObject { let action: String let payload: Data let queuedAt: String + let hostId: String? + let projectId: String? + let projectRootPath: String? } init(database: DatabaseService = DatabaseService()) { @@ -1318,9 +1380,10 @@ final class SyncService: ObservableObject { activeProjectRootPath = normalizedProjectRoot(UserDefaults.standard.string(forKey: activeProjectRootPathKey)) database.setActiveProjectId(activeProjectId) projects = database.listMobileProjects() + outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject(defaultVersion: database.currentDbVersion()) normalizeActiveProjectSelection(allowSingleProjectFallback: false) pendingOperationCount = loadPendingOperations().count - outboundLocalDbVersion = database.currentDbVersion() + resetOutboundCursorStateForActiveProject() activeHostProfile = loadProfile() hostName = activeHostProfile?.hostName latestRemoteDbVersion = activeHostProfile?.lastRemoteDbVersion ?? 0 @@ -1903,14 +1966,14 @@ final class SyncService: ObservableObject { func handleForegroundTransition() async { guard !reconnectConnectInFlight else { return } if canSendLiveRequests() { - do { - try await refreshLaneSnapshots() - try await refreshWorkSessions() - try await refreshPullRequestSnapshots() - lastError = nil - } catch { - lastError = SyncUserFacingError.message(for: error) - } + lastError = nil + await restoreTrackedOpenLanesAfterReconnect() + restoreChatEventSubscriptions() + await refreshRemoteProjectCatalog() + try? await refreshLaneSnapshots() + try? await refreshWorkSessions() + try? await refreshPullRequestSnapshots() + await flushPendingOperations() return } @@ -2136,7 +2199,7 @@ final class SyncService: ObservableObject { connectionState = .disconnected hostName = activeHostProfile?.hostName latestRemoteDbVersion = 0 - outboundLocalDbVersion = database.currentDbVersion() + resetOutboundCursorStateForActiveProject() setDomainStatus(SyncDomain.allCases, phase: .disconnected) if clearCredentials { let profileToClear = activeHostProfile ?? loadProfile() @@ -3182,7 +3245,56 @@ final class SyncService: ObservableObject { func rebaseAbortGit(laneId: String) async throws { _ = try await sendCommand(action: "git.rebaseAbort", args: ["laneId": laneId]) } func listChatModels(provider: String) async throws -> [AgentChatModelInfo] { - try await sendDecodableCommand(action: "chat.models", args: ["provider": provider], as: [AgentChatModelInfo].self) + let normalizedProvider = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let cacheKey = chatModelsCacheKey(provider: normalizedProvider) + let now = Date() + + if let cached = chatModelsCache[cacheKey], + now.timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL { + return cached.models + } + + if let task = chatModelsInFlight[cacheKey] { + return try await task.value + } + + let task = Task { @MainActor [weak self] in + guard let self else { throw CancellationError() } + return try await self.sendDecodableCommand( + action: "chat.models", + args: ["provider": normalizedProvider], + as: [AgentChatModelInfo].self + ) + } + chatModelsInFlight[cacheKey] = task + + do { + let models = try await task.value + chatModelsCache[cacheKey] = ChatModelsCacheEntry(models: models, fetchedAt: now) + chatModelsInFlight[cacheKey] = nil + return models + } catch { + chatModelsInFlight[cacheKey] = nil + if let cached = chatModelsCache[cacheKey] { + return cached.models + } + throw error + } + } + + private func chatModelsCacheKey(provider: String) -> String { + let profile = activeHostProfile + let hostKey = profile?.hostIdentity + ?? profile?.lastHostDeviceId + ?? profile?.lastSuccessfulAddress + ?? currentAddress + ?? hostName + ?? "unpaired-host" + return [ + provider.isEmpty ? "default" : provider, + hostKey, + activeProjectRootPath ?? activeProjectId ?? "no-project", + ].joined(separator: "\u{1f}") } func listChatSessions(laneId: String) async throws -> [AgentChatSessionSummary] { @@ -3882,6 +3994,325 @@ final class SyncService: ObservableObject { } } + private func normalizedProjectId(_ projectId: String?) -> String? { + guard let value = projectId?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private func normalizedHostStorageKey(_ hostId: String?) -> String? { + guard let value = hostId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !value.isEmpty else { + return nil + } + return value + } + + private func activeHostStorageKey() -> String? { + let profile = activeHostProfile + return normalizedHostStorageKey(profile?.hostIdentity) + ?? normalizedHostStorageKey(profile?.lastHostDeviceId) + ?? normalizedHostStorageKey(profile?.lastSuccessfulAddress) + ?? normalizedHostStorageKey(currentAddress) + ?? normalizedHostStorageKey(hostName) + } + + private func outboundStateMatchesActiveHost(_ hostId: String?) -> Bool { + let currentHostId = activeHostStorageKey() + let storedHostId = normalizedHostStorageKey(hostId) + if let currentHostId { + return storedHostId == currentHostId + } + return storedHostId == nil + } + + private func outboundCursorStorageKey(projectId: String?, rootPath: String?, siteId: String, hostId: String?) -> String? { + let normalizedSiteId = siteId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalizedSiteId.isEmpty else { return nil } + let normalizedId = normalizedProjectId(projectId) + let normalizedRoot = normalizedProjectRoot(rootPath) + guard normalizedId != nil || normalizedRoot != nil else { return nil } + var parts = [ + "site=\(normalizedSiteId)", + "project=\(normalizedId ?? "")", + "root=\(normalizedRoot ?? "")", + ] + if let hostId = normalizedHostStorageKey(hostId) { + parts.append("host=\(hostId)") + } + return parts.joined(separator: "|") + } + + private func activeOutboundCursorStorageKey() -> String? { + outboundCursorStorageKey( + projectId: activeProjectId, + rootPath: activeProjectRootPath, + siteId: database.localSiteId(), + hostId: activeHostStorageKey() + ) + } + + private func loadOutboundSyncCursors() -> [String: OutboundSyncCursor] { + guard let data = UserDefaults.standard.data(forKey: outboundSyncCursorsKey) else { + return [:] + } + guard let decoded = try? decoder.decode([String: OutboundSyncCursor].self, from: data) else { + UserDefaults.standard.removeObject(forKey: outboundSyncCursorsKey) + return [:] + } + return decoded.filter { _, cursor in + !cursor.siteId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && cursor.lastAckedDbVersion >= 0 + && (cursor.hostId == nil || normalizedHostStorageKey(cursor.hostId) != nil) + && (normalizedProjectId(cursor.projectId) != nil || normalizedProjectRoot(cursor.projectRootPath) != nil) + } + } + + private func saveOutboundSyncCursors(_ cursors: [String: OutboundSyncCursor]) { + let capped = cappedOutboundSyncState(cursors) { $0.updatedAt } + if capped.isEmpty { + UserDefaults.standard.removeObject(forKey: outboundSyncCursorsKey) + } else if let data = try? encoder.encode(capped) { + UserDefaults.standard.set(data, forKey: outboundSyncCursorsKey) + } + } + + private func loadPendingOutboundChangesets() -> [String: PersistedOutboundChangeset] { + guard let data = UserDefaults.standard.data(forKey: pendingOutboundChangesetsKey) else { + return [:] + } + guard let decoded = try? decoder.decode([String: PersistedOutboundChangeset].self, from: data) else { + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + return [:] + } + return decoded.filter { _, pending in + let siteId = pending.siteId.trimmingCharacters(in: .whitespacesAndNewlines) + let batchId = pending.payload.batchId.trimmingCharacters(in: .whitespacesAndNewlines) + return !siteId.isEmpty + && !batchId.isEmpty + && (pending.hostId == nil || normalizedHostStorageKey(pending.hostId) != nil) + && pending.payload.fromDbVersion >= 0 + && pending.payload.toDbVersion >= pending.payload.fromDbVersion + && !pending.payload.changes.isEmpty + && (normalizedProjectId(pending.projectId) != nil || normalizedProjectRoot(pending.projectRootPath) != nil) + } + } + + private func savePendingOutboundChangesets(_ changesets: [String: PersistedOutboundChangeset]) { + let capped = cappedOutboundSyncState(changesets) { $0.updatedAt } + if capped.isEmpty { + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + } else if let data = try? encoder.encode(capped) { + UserDefaults.standard.set(data, forKey: pendingOutboundChangesetsKey) + } + } + + private func cappedOutboundSyncState( + _ entries: [String: Value], + updatedAt: (Value) -> String + ) -> [String: Value] { + guard entries.count > outboundSyncStateMaxEntries else { return entries } + return Dictionary( + uniqueKeysWithValues: entries + .sorted { left, right in + let leftDate = updatedAt(left.value) + let rightDate = updatedAt(right.value) + if leftDate != rightDate { + return leftDate > rightDate + } + return left.key < right.key + } + .prefix(outboundSyncStateMaxEntries) + .map { ($0.key, $0.value) } + ) + } + + private func matchingOutboundCursor(in cursors: [String: OutboundSyncCursor]) -> OutboundSyncCursor? { + let siteId = database.localSiteId().trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let projectId = normalizedProjectId(activeProjectId) + let rootPath = normalizedProjectRoot(activeProjectRootPath) + guard !siteId.isEmpty, projectId != nil || rootPath != nil else { return nil } + + if let key = activeOutboundCursorStorageKey(), let exact = cursors[key] { + return exact + } + + return cursors.values + .filter { cursor in + cursor.siteId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == siteId + && outboundStateMatchesActiveHost(cursor.hostId) + && ( + (rootPath != nil && normalizedProjectRoot(cursor.projectRootPath) == rootPath) + || (projectId != nil && normalizedProjectId(cursor.projectId) == projectId) + ) + } + .max { left, right in + if left.updatedAt != right.updatedAt { + return left.updatedAt < right.updatedAt + } + return left.lastAckedDbVersion < right.lastAckedDbVersion + } + } + + private func matchingPendingOutboundChangeset(in changesets: [String: PersistedOutboundChangeset]) -> PersistedOutboundChangeset? { + let siteId = database.localSiteId().trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let projectId = normalizedProjectId(activeProjectId) + let rootPath = normalizedProjectRoot(activeProjectRootPath) + guard !siteId.isEmpty, projectId != nil || rootPath != nil else { return nil } + + if let key = activeOutboundCursorStorageKey(), let exact = changesets[key] { + return exact + } + + return changesets.values + .filter { pending in + pending.siteId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == siteId + && outboundStateMatchesActiveHost(pending.hostId) + && ( + (rootPath != nil && normalizedProjectRoot(pending.projectRootPath) == rootPath) + || (projectId != nil && normalizedProjectId(pending.projectId) == projectId) + ) + } + .max { left, right in + if left.updatedAt != right.updatedAt { + return left.updatedAt < right.updatedAt + } + return left.payload.toDbVersion < right.payload.toDbVersion + } + } + + private func persistOutboundCursorForActiveProject(_ dbVersion: Int) { + guard let key = activeOutboundCursorStorageKey() else { return } + var cursors = loadOutboundSyncCursors() + cursors[key] = OutboundSyncCursor( + projectId: normalizedProjectId(activeProjectId), + projectRootPath: normalizedProjectRoot(activeProjectRootPath), + hostId: activeHostStorageKey(), + siteId: database.localSiteId().trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + lastAckedDbVersion: max(0, dbVersion), + updatedAt: syncDateFormatter.string(from: Date()) + ) + saveOutboundSyncCursors(cursors) + } + + private func persistPendingOutboundChangesetForActiveProject(_ pending: PendingOutboundChangeset) { + guard let key = activeOutboundCursorStorageKey() else { return } + var changesets = loadPendingOutboundChangesets() + changesets[key] = PersistedOutboundChangeset( + projectId: normalizedProjectId(activeProjectId), + projectRootPath: normalizedProjectRoot(activeProjectRootPath), + hostId: activeHostStorageKey(), + siteId: database.localSiteId().trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + payload: pending.payload, + retryCount: pending.retryCount, + updatedAt: syncDateFormatter.string(from: Date()) + ) + savePendingOutboundChangesets(changesets) + } + + private func clearPendingOutboundChangesetForActiveProject() { + let siteId = database.localSiteId().trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let projectId = normalizedProjectId(activeProjectId) + let rootPath = normalizedProjectRoot(activeProjectRootPath) + guard !siteId.isEmpty, projectId != nil || rootPath != nil else { return } + var changesets = loadPendingOutboundChangesets() + if let key = activeOutboundCursorStorageKey() { + changesets.removeValue(forKey: key) + } + changesets = changesets.filter { _, pending in + let sameSite = pending.siteId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == siteId + let sameHost = outboundStateMatchesActiveHost(pending.hostId) + let sameProject = projectId != nil && normalizedProjectId(pending.projectId) == projectId + let sameRoot = rootPath != nil && normalizedProjectRoot(pending.projectRootPath) == rootPath + return !(sameHost && sameSite && (sameProject || sameRoot)) + } + savePendingOutboundChangesets(changesets) + } + + private func loadOutboundCursorVersionForActiveProject(defaultVersion: Int) -> Int { + let currentDbVersion = max(0, database.currentDbVersion()) + guard activeOutboundCursorStorageKey() != nil else { + return min(max(0, defaultVersion), currentDbVersion) + } + guard let cursor = matchingOutboundCursor(in: loadOutboundSyncCursors()) else { + let initialVersion = min(max(0, defaultVersion), currentDbVersion) + persistOutboundCursorForActiveProject(initialVersion) + return initialVersion + } + let persistedVersion = max(0, cursor.lastAckedDbVersion) + if persistedVersion > currentDbVersion { + persistOutboundCursorForActiveProject(currentDbVersion) + return currentDbVersion + } + return persistedVersion + } + + private func resetOutboundCursorStateForActiveProject(defaultVersion: Int? = nil) { + let currentDbVersion = max(0, database.currentDbVersion()) + outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject( + defaultVersion: defaultVersion ?? currentDbVersion + ) + guard let persisted = matchingPendingOutboundChangeset(in: loadPendingOutboundChangesets()) else { + pendingOutboundChangeset = nil + return + } + guard persisted.payload.toDbVersion <= currentDbVersion else { + clearPendingOutboundChangesetForActiveProject() + pendingOutboundChangeset = nil + return + } + pendingOutboundChangeset = PendingOutboundChangeset( + payload: persisted.payload, + sentAt: 0, + retryCount: max(0, persisted.retryCount) + ) + outboundLocalDbVersion = min(outboundLocalDbVersion, persisted.payload.fromDbVersion) + } + + private func advanceOutboundCursorForActiveProject(to dbVersion: Int) { + outboundLocalDbVersion = max(outboundLocalDbVersion, max(0, dbVersion)) + persistOutboundCursorForActiveProject(outboundLocalDbVersion) + } + + private func prepareOutboundStateForProjectScopeChange() { + guard activeOutboundCursorStorageKey() != nil else { + pendingOutboundChangeset = nil + return + } + if let pending = pendingOutboundChangeset { + persistPendingOutboundChangesetForActiveProject(pending) + pendingOutboundChangeset = nil + return + } + + let currentDbVersion = database.currentDbVersion() + guard currentDbVersion > outboundLocalDbVersion else { + pendingOutboundChangeset = nil + return + } + let localSiteId = database.localSiteId() + let changes = database.exportChangesSince(version: outboundLocalDbVersion).filter { $0.siteId == localSiteId } + guard !changes.isEmpty else { + advanceOutboundCursorForActiveProject(to: currentDbVersion) + pendingOutboundChangeset = nil + return + } + + let pending = PendingOutboundChangeset( + payload: SyncChangesetBatchPayload( + batchId: makeRequestId(), + reason: "relay", + fromDbVersion: outboundLocalDbVersion, + toDbVersion: currentDbVersion, + changes: changes + ), + sentAt: 0, + retryCount: 0 + ) + persistPendingOutboundChangesetForActiveProject(pending) + pendingOutboundChangeset = nil + } + private func commandDescriptor(for action: String) -> SyncRemoteCommandDescriptor? { remoteCommandDescriptors.first(where: { $0.action == action }) } @@ -4497,6 +4928,7 @@ final class SyncService: ObservableObject { "deviceType": "phone", "siteId": database.localSiteId(), "dbVersion": latestRemoteDbVersion, + "capabilities": ["changesetAck"], ] } @@ -4616,6 +5048,33 @@ final class SyncService: ObservableObject { func prioritizedReconnectAddressesForTesting(_ profile: HostConnectionProfile) -> [String] { prioritizedAddresses(for: profile) } + + func setActiveProjectForTesting(projectId: String?, rootPath: String?) { + setActiveProjectId(projectId, rootPath: rootPath) + resetOutboundCursorStateForActiveProject() + } + + func outboundLocalDbVersionForTesting() -> Int { + outboundLocalDbVersion + } + + func advanceOutboundCursorForTesting(to dbVersion: Int) { + pendingOutboundChangeset = nil + clearPendingOutboundChangesetForActiveProject() + advanceOutboundCursorForActiveProject(to: dbVersion) + } + + func pendingOperationsForTesting() -> [(id: String, kind: String, action: String, projectId: String?, projectRootPath: String?)] { + loadPendingOperations().map { operation in + ( + id: operation.id, + kind: operation.kind, + action: operation.action, + projectId: operation.projectId, + projectRootPath: operation.projectRootPath + ) + } + } #endif private func applyHelloPayload( @@ -4679,6 +5138,23 @@ final class SyncService: ObservableObject { } return false }() + supportsChangesetAck = { + if let changesetAck = features?["changesetAck"] as? [String: Any], + let enabled = changesetAck["enabled"] as? Bool { + return enabled + } + if let value = features?["changesetAck"] as? Bool { + return value + } + if let changesetAck = features?["changeset_ack"] as? [String: Any], + let enabled = changesetAck["enabled"] as? Bool { + return enabled + } + if let value = features?["changeset_ack"] as? Bool { + return value + } + return false + }() remoteProjectCatalog = [] pendingProjectCatalogChunks.removeAll() let commandDescriptors: [SyncRemoteCommandDescriptor] = { @@ -4705,7 +5181,6 @@ final class SyncService: ObservableObject { // The mobile should only claim a dbVersion it actually received via // changeset_batch. Setting it prematurely causes the desktop to skip // the full initial sync on reconnect (it thinks we already have the data). - outboundLocalDbVersion = database.currentDbVersion() hostName = remoteHostName ?? activeHostProfile?.hostName connectionState = .connected currentAddress = connectedHost @@ -4750,6 +5225,11 @@ final class SyncService: ObservableObject { ?? activeHostProfile?.tailscaleAddress ) saveProfile(profile) + resetOutboundCursorStateForActiveProject() + if !supportsChangesetAck { + pendingOutboundChangeset = nil + clearPendingOutboundChangesetForActiveProject() + } startRelayLoop() startInitialHydrationTask(for: connectionGeneration) restoreChatEventSubscriptions() @@ -4886,13 +5366,33 @@ final class SyncService: ObservableObject { resolve(requestId: requestId, result: .success(payload)) case "changeset_batch": let batch = try decode(payload, as: SyncChangesetBatchPayload.self) - let result = try database.applyChanges(batch.changes) - latestRemoteDbVersion = max(latestRemoteDbVersion, batch.toDbVersion, result.dbVersion) - lastSyncAt = Date() - updateProfile { profile in - profile.lastRemoteDbVersion = latestRemoteDbVersion + do { + let result = try database.applyChanges(batch.changes) + latestRemoteDbVersion = max(latestRemoteDbVersion, batch.toDbVersion, result.dbVersion) + lastSyncAt = Date() + updateProfile { profile in + profile.lastRemoteDbVersion = latestRemoteDbVersion + } + sendChangesetAck( + batch: batch, + ok: true, + appliedDbVersion: result.dbVersion, + appliedCount: result.appliedCount + ) + resolve(requestId: requestId, result: .success(payload)) + } catch { + sendChangesetAck( + batch: batch, + ok: false, + appliedDbVersion: database.currentDbVersion(), + appliedCount: 0, + error: error + ) + throw error } - resolve(requestId: requestId, result: .success(payload)) + case "changeset_ack": + let ack = try decode(payload, as: SyncChangesetAckPayload.self) + handleChangesetAck(ack) case "brain_status": if let dict = payload as? [String: Any], let brain = dict["brain"] as? [String: Any] { hostName = brain["deviceName"] as? String @@ -4975,25 +5475,128 @@ final class SyncService: ObservableObject { !Task.isCancelled && connectionGeneration == generation } + private func sendChangesetAck( + batch: SyncChangesetBatchPayload, + ok: Bool, + appliedDbVersion: Int, + appliedCount: Int, + error: Error? = nil + ) { + let ack = SyncChangesetAckPayload( + batchId: batch.batchId, + fromDbVersion: batch.fromDbVersion, + toDbVersion: batch.toDbVersion, + appliedDbVersion: appliedDbVersion, + appliedCount: appliedCount, + ok: ok, + error: error.map { + SyncChangesetAckPayload.AckError( + code: "changeset_apply_failed", + message: SyncUserFacingError.message(for: $0) + ) + } + ) + guard let payloadObject = try? jsonObject(from: ack) else { return } + sendEnvelope(type: "changeset_ack", requestId: batch.batchId, payload: payloadObject) + } + + private func handleChangesetAck(_ ack: SyncChangesetAckPayload) { + guard var pending = pendingOutboundChangeset else { return } + guard ack.batchId == pending.payload.batchId else { + syncConnectLog.info("changeset ack ignored batch=\(ack.batchId, privacy: .public)") + return + } + guard ack.toDbVersion >= pending.payload.toDbVersion else { return } + if ack.ok { + pendingOutboundChangeset = nil + clearPendingOutboundChangesetForActiveProject() + advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) + lastSyncAt = Date() + lastError = nil + return + } + guard pending.retryCount < maxChangesetAckRetries else { + failPendingOutboundChangeset("The desktop stopped accepting phone changes. Reconnecting now.") + return + } + pending.retryCount += 1 + pending.sentAt = ProcessInfo.processInfo.systemUptime + pendingOutboundChangeset = pending + persistPendingOutboundChangesetForActiveProject(pending) + lastError = ack.error?.message ?? "The desktop could not apply the latest phone changes." + } + + private func sendOutboundChangeset(_ pending: PendingOutboundChangeset) { + guard let payloadObject = try? jsonObject(from: pending.payload) else { return } + sendEnvelope(type: "changeset_batch", requestId: pending.payload.batchId, payload: payloadObject) + } + private func sendLocalChanges() { guard canSendLiveRequests() else { return } + let now = ProcessInfo.processInfo.systemUptime + if var pending = pendingOutboundChangeset { + if !supportsChangesetAck { + sendOutboundChangeset(pending) + pendingOutboundChangeset = nil + clearPendingOutboundChangesetForActiveProject() + advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) + lastSyncAt = Date() + return + } + if now - pending.sentAt >= 10 { + guard pending.retryCount < maxChangesetAckRetries else { + failPendingOutboundChangeset("The desktop did not acknowledge phone changes in time. Reconnecting now.") + return + } + pending.sentAt = now + pending.retryCount += 1 + pendingOutboundChangeset = pending + persistPendingOutboundChangesetForActiveProject(pending) + sendOutboundChangeset(pending) + } + return + } let currentDbVersion = database.currentDbVersion() guard currentDbVersion > outboundLocalDbVersion else { return } let localSiteId = database.localSiteId() let changes = database.exportChangesSince(version: outboundLocalDbVersion).filter { $0.siteId == localSiteId } let previousDbVersion = outboundLocalDbVersion - outboundLocalDbVersion = currentDbVersion - guard !changes.isEmpty else { return } + guard !changes.isEmpty else { + advanceOutboundCursorForActiveProject(to: currentDbVersion) + return + } let payload = SyncChangesetBatchPayload( + batchId: makeRequestId(), reason: "relay", fromDbVersion: previousDbVersion, toDbVersion: currentDbVersion, changes: changes ) latestRemoteDbVersion = max(latestRemoteDbVersion, currentDbVersion) - guard let payloadObject = try? jsonObject(from: payload) else { return } - sendEnvelope(type: "changeset_batch", requestId: nil, payload: payloadObject) + let pending = PendingOutboundChangeset(payload: payload, sentAt: now, retryCount: 0) + sendOutboundChangeset(pending) + if supportsChangesetAck { + pendingOutboundChangeset = pending + persistPendingOutboundChangesetForActiveProject(pending) + } else { + advanceOutboundCursorForActiveProject(to: payload.toDbVersion) + lastSyncAt = Date() + } + } + + private func failPendingOutboundChangeset(_ message: String) { + pendingOutboundChangeset = nil + clearPendingOutboundChangesetForActiveProject() + handleTransportFailure( + NSError( + domain: "ADE", + code: 27, + userInfo: [NSLocalizedDescriptionKey: message] + ), + phase: .disconnected, + connectionState: .disconnected + ) } private func resolve(requestId: String?, result: Result) { @@ -5256,22 +5859,48 @@ final class SyncService: ObservableObject { pendingOperationCount = operations.count } - private func enqueueOperation(kind: String, action: String, args: [String: Any]) throws { + private func enqueueOperation(kind: String, action: String, args: [String: Any], id: String? = nil) throws { guard JSONSerialization.isValidJSONObject(args) else { throw NSError(domain: "ADE", code: 11, userInfo: [NSLocalizedDescriptionKey: "Invalid queued operation payload."]) } let payload = try adeJSONData(withJSONObject: args) var queued = loadPendingOperations() queued.append(PendingOperation( - id: makeRequestId(), + id: id ?? makeRequestId(), kind: kind, action: action, payload: payload, - queuedAt: ISO8601DateFormatter().string(from: Date()) + queuedAt: syncDateFormatter.string(from: Date()), + hostId: activeHostStorageKey(), + projectId: activeProjectId, + projectRootPath: activeProjectRootPath )) savePendingOperations(queued) } + private func pendingOperationMatchesActiveProject(_ operation: PendingOperation) -> Bool { + let currentHostId = activeHostStorageKey() + let operationHostId = normalizedHostStorageKey(operation.hostId) + if let currentHostId { + guard operationHostId == currentHostId else { return false } + } else if operationHostId != nil { + return false + } + + if operation.projectId == nil && operation.projectRootPath == nil { + return true + } + if let projectId = operation.projectId, projectId == activeProjectId { + return true + } + if let operationRoot = normalizedProjectRoot(operation.projectRootPath), + let activeRoot = normalizedProjectRoot(activeProjectRootPath), + operationRoot == activeRoot { + return true + } + return false + } + private func decodeQueuedArgs(_ operation: PendingOperation) throws -> [String: Any] { let raw = try JSONSerialization.jsonObject(with: operation.payload, options: []) guard let dict = raw as? [String: Any] else { @@ -5288,8 +5917,13 @@ final class SyncService: ObservableObject { return } - while !queued.isEmpty { - let operation = queued[0] + var index = queued.startIndex + while index < queued.endIndex { + let operation = queued[index] + guard pendingOperationMatchesActiveProject(operation) else { + index = queued.index(after: index) + continue + } do { let args = try decodeQueuedArgs(operation) switch operation.kind { @@ -5297,7 +5931,7 @@ final class SyncService: ObservableObject { guard commandPolicy(for: operation.action) != nil else { throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Queued action \(operation.action) is no longer available on this host."]) } - _ = try await performCommandRequest(action: operation.action, args: args) + _ = try await performCommandRequest(action: operation.action, args: args, commandId: operation.id) case "file": guard queueableFileActions.contains(operation.action) else { throw NSError(domain: "ADE", code: 17, userInfo: [NSLocalizedDescriptionKey: "Queued file action \(operation.action) is no longer supported."]) @@ -5306,12 +5940,12 @@ final class SyncService: ObservableObject { default: throw NSError(domain: "ADE", code: 13, userInfo: [NSLocalizedDescriptionKey: "Unknown queued operation type."]) } - queued.removeFirst() + queued.remove(at: index) savePendingOperations(queued) } catch { lastError = SyncUserFacingError.message(for: error) if canSendLiveRequests() { - queued.removeFirst() + queued.remove(at: index) savePendingOperations(queued) continue } @@ -5325,13 +5959,14 @@ final class SyncService: ObservableObject { private func performCommandRequest( action: String, args: [String: Any], + commandId: String? = nil, disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message ) async throws -> Any { guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The host is offline."]) } - let requestId = makeRequestId() + let requestId = commandId ?? makeRequestId() let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: disconnectOnTimeout, @@ -5347,8 +5982,19 @@ final class SyncService: ObservableObject { } private func sendCommand(action: String, args: [String: Any]) async throws -> Any { + let commandId = makeRequestId() if canSendLiveRequests() { - return try await performCommandRequest(action: action, args: args) + do { + return try await performCommandRequest(action: action, args: args, commandId: commandId) + } catch { + if !isRemoteCommandApplicationError(error), + !canSendLiveRequests(), + commandPolicy(for: action)?.queueable == true { + try enqueueOperation(kind: "command", action: action, args: args, id: commandId) + return ["queued": true] + } + throw error + } } guard let policy = commandPolicy(for: action) else { throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action is not available for the current host. Reconnect to refresh lane capabilities."]) @@ -5776,13 +6422,24 @@ extension SyncService { } private func performCommandRequestSafe(action: String, args: [String: Any]) async throws -> Any { + let commandId = makeRequestId() if canSendLiveRequests() { - return try await performCommandRequest(action: action, args: args) + do { + return try await performCommandRequest(action: action, args: args, commandId: commandId) + } catch { + if !isRemoteCommandApplicationError(error), + !canSendLiveRequests(), + commandPolicy(for: action)?.queueable == true { + try enqueueOperation(kind: "command", action: action, args: args, id: commandId) + return ["queued": true] + } + throw error + } } guard let policy = commandPolicy(for: action), policy.queueable == true else { throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "Offline — command dropped."]) } - try enqueueOperation(kind: "command", action: action, args: args) + try enqueueOperation(kind: "command", action: action, args: args, id: commandId) return ["queued": true] } diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 21e9aaec0..2c8dce14f 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -129,37 +129,67 @@ enum ADEColor { "anthropic/claude-haiku-4-5": 0x06B6D4, "haiku": 0x06B6D4, // OpenAI / Codex - "openai/gpt-5.5-codex": 0x10A37F, - "gpt-5.5-codex": 0x10A37F, - "openai/gpt-5.4-codex": 0x10A37F, - "gpt-5.4-codex": 0x10A37F, - "openai/gpt-5.4-mini-codex": 0x34D399, - "gpt-5.4-mini-codex": 0x34D399, + "openai/gpt-5.5": 0x10A37F, + "gpt-5.5": 0x10A37F, + "openai/gpt-5.4": 0x10A37F, + "gpt-5.4": 0x10A37F, + "openai/gpt-5.4-mini": 0x34D399, + "gpt-5.4-mini": 0x34D399, "openai/gpt-5.3-codex": 0x10B981, "gpt-5.3-codex": 0x10B981, - "openai/gpt-5.3-codex-spark": 0x34D399, - "gpt-5.3-codex-spark": 0x34D399, - "openai/gpt-5.2-codex": 0x10B981, - "gpt-5.2-codex": 0x10B981, - "openai/gpt-5.1-codex-max": 0x10B981, - "gpt-5.1-codex-max": 0x10B981, - "openai/gpt-5.1-codex-mini": 0x2DD4BF, - "gpt-5.1-codex-mini": 0x2DD4BF, // Local "ollama/llama-3.3": 0x71717A, "llama-3.3": 0x71717A, ] + private static func modelLookupCandidates(for modelId: String?) -> [String] { + guard let raw = modelId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return [] } + var candidates: [String] = [] + func append(_ value: String) { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !normalized.isEmpty && !candidates.contains(normalized) { + candidates.append(normalized) + } + } + + append(raw) + let lower = raw.lowercased() + if lower.hasPrefix("openai/") { + append(String(lower.dropFirst("openai/".count))) + } + if lower.hasPrefix("anthropic/") { + append(String(lower.dropFirst("anthropic/".count))) + } + + switch lower { + case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": + append("openai/gpt-5.5") + append("gpt-5.5") + case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": + append("openai/gpt-5.4") + append("gpt-5.4") + case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex": + append("openai/gpt-5.4-mini") + append("gpt-5.4-mini") + case "opus[1m]", "opus-1m", "anthropic/claude-opus-4-7-1m", "claude-opus-4-7-1m", "claude-opus-4-7[1m]": + append("anthropic/claude-opus-4-7-1m") + append("opus-1m") + default: + break + } + + return candidates + } + /// Resolve a model id (registry id or short id) to its brand color. /// Returns nil when the model isn't in the registry; callers should fall back to `providerBrand`. static func modelBrand(for modelId: String?) -> Color? { - guard let raw = modelId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - if let hexValue = modelColors[raw] { - return Color(uiColor: hex(hexValue)) - } - let lower = raw.lowercased() - if let hexValue = modelColors[lower] { - return Color(uiColor: hex(hexValue)) + let candidates = modelLookupCandidates(for: modelId) + guard let lower = candidates.first else { return nil } + for candidate in candidates { + if let hexValue = modelColors[candidate] { + return Color(uiColor: hex(hexValue)) + } } // Heuristic fallback for dynamic Cursor SDK ids: `cursor/`. if lower.hasPrefix("cursor/") { @@ -197,32 +227,23 @@ enum ADEColor { "sonnet": ["low", "medium", "high"], // Claude Haiku intentionally absent — no reasoning tiers. // OpenAI / Codex - "openai/gpt-5.5-codex": ["low", "medium", "high", "xhigh"], - "gpt-5.5-codex": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.4-codex": ["low", "medium", "high", "xhigh"], - "gpt-5.4-codex": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.4-mini-codex": ["low", "medium", "high", "xhigh"], - "gpt-5.4-mini-codex": ["low", "medium", "high", "xhigh"], + "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], + "gpt-5.5": ["low", "medium", "high", "xhigh"], + "openai/gpt-5.4": ["low", "medium", "high", "xhigh"], + "gpt-5.4": ["low", "medium", "high", "xhigh"], + "openai/gpt-5.4-mini": ["low", "medium", "high", "xhigh"], + "gpt-5.4-mini": ["low", "medium", "high", "xhigh"], "openai/gpt-5.3-codex": ["low", "medium", "high", "xhigh"], "gpt-5.3-codex": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.3-codex-spark": ["low", "medium", "high", "xhigh"], - "gpt-5.3-codex-spark": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.2-codex": ["low", "medium", "high", "xhigh"], - "gpt-5.2-codex": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.1-codex-max": ["low", "medium", "high", "xhigh"], - "gpt-5.1-codex-max": ["low", "medium", "high", "xhigh"], - "openai/gpt-5.1-codex-mini": ["medium", "high"], - "gpt-5.1-codex-mini": ["medium", "high"], ] /// Return the reasoning tiers supported by a model, or nil when the model /// doesn't expose tiers (e.g. Haiku). Used by the composer to decide whether /// to render the effort picker. static func reasoningTiers(for modelId: String?) -> [String]? { - guard let raw = modelId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - if let tiers = modelReasoningTiers[raw] { return tiers } - let lower = raw.lowercased() - if let tiers = modelReasoningTiers[lower] { return tiers } + for candidate in modelLookupCandidates(for: modelId) { + if let tiers = modelReasoningTiers[candidate] { return tiers } + } return nil } diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index a7419a0bc..7538ce6e7 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -335,12 +335,15 @@ struct WorkComposerChipStrip: View { private func prettyModelName(_ model: String) -> String { // Match the desktop composer's model label: "Claude Sonnet 4.6" / - // "GPT-5.4-Codex" instead of a bare short id. Host-reported + // "GPT-5.4" instead of a bare short id. Host-reported // `chatSummary.model` is usually just "sonnet" / "opus" / "haiku" for // Claude and the full long form for Codex, so we special-case the // Claude short ids and otherwise beautify the raw string. let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "Model" } + if let known = workKnownModelDisplayName(trimmed) { + return known + } let lower = trimmed.lowercased() switch lower { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 551d46564..5920f1717 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -51,16 +51,11 @@ struct WorkChatSessionView: View { let onSelectRuntimeMode: @MainActor (String) async -> Void let onSelectEffort: @MainActor (String) async -> Void - /// Optional lane list forwarded from the parent so the `@`-mention picker can offer lane names. - /// When nil the `@` button is still shown but the sheet will display an empty list. var lanes: [LaneSummary] = [] @State var steerEditDrafts: [String: String] = [:] @State var modelPickerPresented = false @State var modelUpdateInFlight = false - @State var mentionsSheetPresented = false - @State var slashSheetPresented = false - @State var pendingComposerInsert: String? var sessionStatus: String { normalizedWorkChatSessionStatus(session: session, summary: chatSummary) @@ -361,9 +356,6 @@ struct WorkChatSessionView: View { artifactRefreshInFlight: artifactRefreshInFlight, artifactRefreshError: artifactRefreshError, onOpenProof: { artifactDrawerPresented = true }, - pendingInsert: $pendingComposerInsert, - onOpenMentions: { mentionsSheetPresented = true }, - onOpenSlash: { slashSheetPresented = true }, onSend: onSend, onSent: { scrollToLatest(proxy, animated: true) @@ -502,18 +494,6 @@ struct WorkChatSessionView: View { } ) } - .sheet(isPresented: $mentionsSheetPresented) { - WorkMentionsPickerSheet(lanes: lanes) { token in - pendingComposerInsert = token - mentionsSheetPresented = false - } - } - .sheet(isPresented: $slashSheetPresented) { - WorkSlashCommandsSheet(provider: chatSummary?.provider ?? session.toolType ?? "") { token in - pendingComposerInsert = token - slashSheetPresented = false - } - } } } } @@ -582,16 +562,13 @@ private struct WorkChatComposerCard: View { let artifactRefreshInFlight: Bool let artifactRefreshError: String? let onOpenProof: () -> Void - @Binding var pendingInsert: String? - let onOpenMentions: () -> Void - let onOpenSlash: () -> Void let onSend: @MainActor (String) async -> Bool let onSent: () -> Void var body: some View { - WorkChatComposerDraftInput( - chatSummary: chatSummary, - queuedSteerCount: queuedSteerCount, + WorkChatComposerDraftInput( + chatSummary: chatSummary, + queuedSteerCount: queuedSteerCount, pendingInputCount: pendingInputCount, canCompose: canCompose, canSend: canSend, @@ -603,16 +580,13 @@ private struct WorkChatComposerCard: View { onSelectRuntimeMode: onSelectRuntimeMode, onSelectEffort: onSelectEffort, artifactCount: artifactCount, - latestArtifact: latestArtifact, - artifactRefreshInFlight: artifactRefreshInFlight, - artifactRefreshError: artifactRefreshError, - onOpenProof: onOpenProof, - pendingInsert: $pendingInsert, - onOpenMentions: onOpenMentions, - onOpenSlash: onOpenSlash, - onSend: onSend, - onSent: onSent - ) + latestArtifact: latestArtifact, + artifactRefreshInFlight: artifactRefreshInFlight, + artifactRefreshError: artifactRefreshError, + onOpenProof: onOpenProof, + onSend: onSend, + onSent: onSent + ) .padding(.horizontal, 14) .padding(.vertical, 14) .background(composerSurface) @@ -659,9 +633,6 @@ private struct WorkChatComposerDraftInput: View { let artifactRefreshInFlight: Bool let artifactRefreshError: String? let onOpenProof: () -> Void - @Binding var pendingInsert: String? - let onOpenMentions: () -> Void - let onOpenSlash: () -> Void let onSend: @MainActor (String) async -> Bool let onSent: () -> Void @@ -681,8 +652,7 @@ private struct WorkChatComposerDraftInput: View { VStack(alignment: .leading, spacing: 12) { WorkChatComposerTextField( draftState: draftState, - canCompose: canCompose, - pendingInsert: $pendingInsert + canCompose: canCompose ) HStack(alignment: .center, spacing: 8) { @@ -697,46 +667,6 @@ private struct WorkChatComposerDraftInput: View { Spacer(minLength: 0) - // @ mentions button - Button(action: onOpenMentions) { - Image(systemName: "at") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) - .background(ADEColor.raisedBackground.opacity(0.7), in: Circle()) - .overlay(Circle().stroke(ADEColor.glassBorder, lineWidth: 0.6)) - } - .buttonStyle(.plain) - .disabled(!canCompose) - .accessibilityLabel("Insert @ mention") - .adeInspectable( - "Work.Chat.Composer.MentionsButton", - metadata: [ - "label": "Insert @ mention", - "role": "button" - ] - ) - - // / slash-command button - Button(action: onOpenSlash) { - Text("/") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) - .background(ADEColor.raisedBackground.opacity(0.7), in: Circle()) - .overlay(Circle().stroke(ADEColor.glassBorder, lineWidth: 0.6)) - } - .buttonStyle(.plain) - .disabled(!canCompose) - .accessibilityLabel("Insert slash command") - .adeInspectable( - "Work.Chat.Composer.SlashButton", - metadata: [ - "label": "Insert slash command", - "role": "button" - ] - ) - WorkProofComposerButton( count: artifactCount, latestArtifact: latestArtifact, @@ -817,13 +747,6 @@ private final class WorkChatComposerDraftState: ObservableObject { return value } - func insertToken(_ token: String) { - if !text.isEmpty && !text.hasSuffix(" ") && !text.hasSuffix("\n") { - text += " " - } - text += token - } - func restoreUnsentText(_ value: String) { let currentDraft = trimmedText guard currentDraft != value else { return } @@ -838,7 +761,6 @@ private final class WorkChatComposerDraftState: ObservableObject { private struct WorkChatComposerTextField: View { @ObservedObject var draftState: WorkChatComposerDraftState let canCompose: Bool - @Binding var pendingInsert: String? @FocusState private var composerFocused: Bool var body: some View { @@ -853,12 +775,6 @@ private struct WorkChatComposerTextField: View { .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - .onChange(of: pendingInsert) { _, token in - guard let token, !token.isEmpty else { return } - draftState.insertToken(token) - pendingInsert = nil - composerFocused = true - } } } diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 8cd625be7..dbe9fd004 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -110,14 +110,10 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { key: "openai", displayName: "OpenAI", models: [ - WorkModelOption(id: "gpt-5.5-codex", displayName: "GPT-5.5", tier: .flagship, tagline: "Flagship · 400K context", provider: "codex"), - WorkModelOption(id: "gpt-5.4-codex", displayName: "GPT-5.4", tier: .flagship, tagline: "Flagship · 400K context", provider: "codex"), - WorkModelOption(id: "gpt-5.4-mini-codex", displayName: "GPT-5.4-Mini", tier: .fast, tagline: "Cheaper 1M-context variant", provider: "codex"), + WorkModelOption(id: "gpt-5.5", displayName: "GPT-5.5", tier: .flagship, tagline: "Flagship · 1M context", provider: "codex"), + WorkModelOption(id: "gpt-5.4", displayName: "GPT-5.4", tier: .flagship, tagline: "Affordable · 1M context", provider: "codex"), + WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4-Mini", tier: .fast, tagline: "Cheaper 1M-context variant", provider: "codex"), WorkModelOption(id: "gpt-5.3-codex", displayName: "GPT-5.3-Codex", tier: .balanced, tagline: "Tuned for code edits", provider: "codex"), - WorkModelOption(id: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark", tier: .balanced, tagline: "Faster Codex variant", provider: "codex"), - WorkModelOption(id: "gpt-5.2-codex", displayName: "GPT-5.2-Codex", tier: .balanced, tagline: "Prior-gen Codex", provider: "codex"), - WorkModelOption(id: "gpt-5.1-codex-max", displayName: "GPT-5.1-Codex-Max", tier: .flagship, tagline: "Long-running Codex turns", provider: "codex"), - WorkModelOption(id: "gpt-5.1-codex-mini", displayName: "GPT-5.1-Codex-Mini", tier: .fast, tagline: "Lowest-cost Codex", provider: "codex"), ] ) ] @@ -177,12 +173,8 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { WorkModelOption(id: "gpt-5.4-fast", displayName: "GPT-5.4 Fast", tier: .flagship, tagline: "Faster GPT-5.4", provider: "codex"), WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4 Mini", tier: .fast, tagline: "Cheaper general-purpose", provider: "codex"), WorkModelOption(id: "gpt-5.3-codex", displayName: "GPT-5.3-Codex (0.7x)", tier: .balanced, tagline: "Tuned for code edits", provider: "codex"), - WorkModelOption(id: "gpt-5.3-codex-fast", displayName: "GPT-5.3-Codex Fast", tier: .balanced, tagline: "Faster Codex variant", provider: "codex"), WorkModelOption(id: "gpt-5.2", displayName: "GPT-5.2 (0.7x)", tier: .balanced, tagline: "Prior-gen GPT-5", provider: "codex"), - WorkModelOption(id: "gpt-5.2-codex", displayName: "GPT-5.2-Codex (0.7x)", tier: .balanced, tagline: "Prior-gen Codex", provider: "codex"), WorkModelOption(id: "gpt-5.1", displayName: "GPT-5.1 (0.5x)", tier: .balanced, tagline: "Older GPT-5", provider: "codex"), - WorkModelOption(id: "gpt-5.1-codex", displayName: "GPT-5.1-Codex (0.5x)", tier: .balanced, tagline: "Older Codex", provider: "codex"), - WorkModelOption(id: "gpt-5.1-codex-max", displayName: "GPT-5.1-Codex-Max (0.5x)", tier: .flagship, tagline: "Long-running Codex turns", provider: "codex"), ] ), WorkModelProvider( @@ -302,11 +294,12 @@ func workModelCatalogGroups( var modelsByProvider: [String: [WorkModelOption]] = [:] for model in availableModels { let providerKey = workModelProviderKey(for: model, topLevelProvider: groupKey) + let curated = workCuratedModelLookupMatch(for: model, in: curatedModelLookup) let option = workDynamicModelOption( from: model, topLevelProvider: groupKey, providerKey: providerKey, - curated: curatedModelLookup[model.id] + curated: curated ) modelsByProvider[providerKey, default: []].append(option) } @@ -348,13 +341,116 @@ private func workCuratedModelLookup(from groups: [WorkModelCatalogGroup]) -> [St for group in groups { for provider in group.providers { for model in provider.models { - lookup[model.id] = model + for key in workModelLookupKeys(model.id) { + lookup[key] = model + } } } } return lookup } +private func workCuratedModelLookupMatch( + for model: AgentChatModelInfo, + in lookup: [String: WorkModelOption] +) -> WorkModelOption? { + for key in workModelLookupKeys(model.id) { + if let match = lookup[key] { return match } + } + if let canonical = model.modelId { + for key in workModelLookupKeys(canonical) { + if let match = lookup[key] { return match } + } + } + return nil +} + +private func workModelLookupKeys(_ raw: String?) -> [String] { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + + var keys: [String] = [] + func append(_ value: String) { + let key = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !key.isEmpty && !keys.contains(key) { + keys.append(key) + } + } + + append(trimmed) + if let registryId = workCanonicalCodexRegistryId(for: trimmed) { + append(registryId) + } + if let runtimeId = workCodexRuntimeModelId(for: trimmed) { + append(runtimeId) + } + if trimmed.lowercased().hasPrefix("openai/") { + append(String(trimmed.dropFirst("openai/".count))) + } + if trimmed.lowercased().hasPrefix("anthropic/") { + append(String(trimmed.dropFirst("anthropic/".count))) + } + + return keys +} + +private func workCanonicalCodexRegistryId(for raw: String) -> String? { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": + return "openai/gpt-5.5" + case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": + return "openai/gpt-5.4" + case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex": + return "openai/gpt-5.4-mini" + case "gpt-5.3-codex", "openai/gpt-5.3-codex": + return "openai/gpt-5.3-codex" + default: + return nil + } +} + +private func workCodexRuntimeModelId(for raw: String) -> String? { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": + return "gpt-5.5" + case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": + return "gpt-5.4" + case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex": + return "gpt-5.4-mini" + default: + return nil + } +} + +func workModelIdsEquivalent(_ lhs: String?, _ rhs: String?) -> Bool { + let lhsKeys = Set(workModelLookupKeys(lhs)) + let rhsKeys = Set(workModelLookupKeys(rhs)) + return !lhsKeys.isDisjoint(with: rhsKeys) +} + +func workKnownModelDisplayName(_ raw: String?) -> String? { + switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" { + case "opus", "anthropic/claude-opus-4-7", "claude-opus-4-7": + return "Claude Opus 4.7" + case "opus[1m]", "opus-1m", "anthropic/claude-opus-4-7-1m", "claude-opus-4-7-1m", "claude-opus-4-7[1m]": + return "Claude Opus 4.7 1M" + case "sonnet", "anthropic/claude-sonnet-4-6", "claude-sonnet-4-6": + return "Claude Sonnet 4.6" + case "haiku", "anthropic/claude-haiku-4-5", "claude-haiku-4-5": + return "Claude Haiku 4.5" + case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": + return "GPT-5.5" + case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": + return "GPT-5.4" + case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex": + return "GPT-5.4-Mini" + case "gpt-5.3-codex", "openai/gpt-5.3-codex": + return "GPT-5.3-Codex" + default: + return nil + } +} + private func workProviderDisplayName( groupKey: String, providerKey: String, @@ -405,7 +501,7 @@ private func workModelSortOrder( let provider = group.providers.first(where: { $0.key == providerKey }) else { return Int.max } - return provider.models.firstIndex(where: { $0.id == modelId }) ?? Int.max - 1 + return provider.models.firstIndex(where: { workModelIdsEquivalent($0.id, modelId) }) ?? Int.max - 1 } private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvider: String) -> String { @@ -543,13 +639,21 @@ private func workDeduplicatedModelOptions(_ models: [WorkModelOption]) -> [WorkM var seen = Set() var deduplicated: [WorkModelOption] = [] for model in models { - if seen.insert(model.id).inserted { + if seen.insert(workModelEquivalenceKey(model.id)).inserted { deduplicated.append(model) } } return deduplicated } +private func workModelEquivalenceKey(_ raw: String?) -> String { + let keys = workModelLookupKeys(raw) + return keys.first(where: { !$0.contains("/") }) + ?? keys.first + ?? raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + ?? "" +} + private func injectCurrentWorkModelIfNeeded( into initialGroups: [WorkModelCatalogGroup], currentModelId: String, @@ -562,7 +666,7 @@ private func injectCurrentWorkModelIfNeeded( // matching group, or append a lightweight "Other" group when no group matches. if !currentModelId.isEmpty { let alreadyPresent = groups.contains { g in - g.providers.contains { p in p.models.contains { $0.id == currentModelId } } + g.providers.contains { p in p.models.contains { workModelIdsEquivalent($0.id, currentModelId) } } } if !alreadyPresent { let providerLower = currentProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 4a436f0f9..ee721202b 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -221,7 +221,7 @@ struct WorkModelPickerSheet: View { } if let provider = block.providers.first(where: { provider in - provider.models.contains { $0.id == currentModelId } + provider.models.contains { workModelIdsEquivalent($0.id, currentModelId) } }) { return provider.key } @@ -267,8 +267,11 @@ struct WorkModelPickerSheet: View { if lower.contains("sonnet") || lower.contains("thinking") { return ["low", "medium", "high"] } + if lower.contains("gpt-5.4-mini") { + return ["low", "medium", "high", "xhigh"] + } if lower.contains("gpt-5") { - return lower.contains("mini") ? ["medium", "high"] : ["low", "medium", "high", "xhigh"] + return ["low", "medium", "high", "xhigh"] } return [] } @@ -554,12 +557,10 @@ struct WorkModelPickerSheet: View { @ViewBuilder private func modelButton(model: WorkModelOption) -> some View { let tiers = supportedReasoningTiers(for: model) - let isSelected = model.id == currentModelId + let isSelected = workModelIdsEquivalent(model.id, currentModelId) VStack(alignment: .leading, spacing: 0) { - // Card header is always tappable: tapping the header commits the model - // with `effort: nil` (server default) even for reasoning-capable models, - // so users who don't care about a specific tier aren't forced to pick one. Button { + guard tiers.isEmpty else { return } commit(model: model, effort: nil) } label: { modelHeaderRow(model: model, isSelected: isSelected) @@ -567,7 +568,7 @@ struct WorkModelPickerSheet: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isBusy) + .disabled(isBusy || !tiers.isEmpty) if !tiers.isEmpty { reasoningPills(model: model, tiers: tiers) @@ -647,7 +648,7 @@ struct WorkModelPickerSheet: View { /// currently-active effort for the active model so users see what's set. @ViewBuilder private func reasoningPills(model: WorkModelOption, tiers: [String]) -> some View { - let isActiveModel = model.id == currentModelId + let isActiveModel = workModelIdsEquivalent(model.id, currentModelId) let normalizedCurrent = currentReasoningEffort .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -700,7 +701,7 @@ struct WorkModelPickerSheet: View { .lowercased() let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort - if model.id == currentModelId && !effortChanged { + if workModelIdsEquivalent(model.id, currentModelId) && !effortChanged { dismiss() return } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 69d0b60e0..e523c506d 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -24,9 +24,6 @@ struct WorkNewChatScreen: View { @State private var modelPickerPresented = false @State private var runtimeMode: String = "default" @State private var reasoningEffort: String = "" - @State private var mentionsSheetPresented = false - @State private var slashSheetPresented = false - @State private var pendingDraftInsert: String? private var selectedLaneName: String { if let match = lanes.first(where: { $0.id == selectedLaneId }) { @@ -116,18 +113,6 @@ struct WorkNewChatScreen: View { } ) } - .sheet(isPresented: $mentionsSheetPresented) { - WorkMentionsPickerSheet(lanes: lanes) { token in - pendingDraftInsert = token - mentionsSheetPresented = false - } - } - .sheet(isPresented: $slashSheetPresented) { - WorkSlashCommandsSheet(provider: provider) { token in - pendingDraftInsert = token - slashSheetPresented = false - } - } } @ViewBuilder @@ -210,10 +195,7 @@ struct WorkNewChatScreen: View { canStart: !busy && !selectedLaneId.isEmpty && !modelId.isEmpty, runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, - pendingInsert: $pendingDraftInsert, onOpenModelPicker: { modelPickerPresented = true }, - onOpenMentions: { mentionsSheetPresented = true }, - onOpenSlash: { slashSheetPresented = true }, onSubmit: submit(openingMessage:) ) } @@ -221,6 +203,9 @@ struct WorkNewChatScreen: View { private func prettyNewChatModelName(_ model: String) -> String { let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "Model" } + if let known = workKnownModelDisplayName(trimmed) { + return known + } let lower = trimmed.lowercased() switch lower { case "opus": return "Claude Opus 4.7" @@ -283,10 +268,7 @@ private struct WorkNewChatComposerBar: View { let canStart: Bool @Binding var runtimeMode: String @Binding var reasoningEffort: String - @Binding var pendingInsert: String? let onOpenModelPicker: () -> Void - let onOpenMentions: () -> Void - let onOpenSlash: () -> Void let onSubmit: @MainActor (String) async -> Bool @State private var draft: String = "" @@ -324,119 +306,86 @@ private struct WorkNewChatComposerBar: View { .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - .onChange(of: pendingInsert) { _, newValue in - guard let token = newValue, !token.isEmpty else { return } - if !draft.isEmpty && !draft.hasSuffix(" ") && !draft.hasSuffix("\n") { - draft += " " - } - draft += token - pendingInsert = nil - composerFocused = true - } HStack(alignment: .center, spacing: 8) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .center, spacing: 10) { - Button { - onOpenModelPicker() - } label: { - HStack(spacing: 6) { - WorkProviderLogo( - provider: provider, - fallbackSymbol: providerIcon(provider), - tint: providerTint(provider), - size: 16 - ) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if !reasoningEffort.isEmpty { - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(reasoningEffort.capitalized) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .center, spacing: 10) { + Button { + onOpenModelPicker() + } label: { + HStack(spacing: 6) { + WorkProviderLogo( + provider: provider, + fallbackSymbol: providerIcon(provider), + tint: providerTint(provider), + size: 16 + ) + Text(modelName) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if !reasoningEffort.isEmpty { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(reasoningEffort.capitalized) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.clear, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) } - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) + .buttonStyle(.plain) - if !runtimeOptions.isEmpty { - Menu { - ForEach(runtimeOptions) { option in - Button { - runtimeMode = option.id + if !runtimeOptions.isEmpty { + Menu { + ForEach(runtimeOptions) { option in + Button { + runtimeMode = option.id + } label: { + if option.id == runtimeMode { + Label(option.title, systemImage: "checkmark") + } else { + Text(option.title) + } + } + } } label: { - if option.id == runtimeMode { - Label(option.title, systemImage: "checkmark") - } else { - Text(option.title) + HStack(spacing: 6) { + Circle().fill(runtimeTint).frame(width: 6, height: 6) + Text(runtimeLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(runtimeTint.opacity(0.06), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(runtimeTint.opacity(0.22), lineWidth: 0.5) + ) } + .menuStyle(.borderlessButton) + .buttonStyle(.plain) + .accessibilityLabel("Access mode: \(runtimeLabel). Tap to change.") } - } label: { - HStack(spacing: 6) { - Circle().fill(runtimeTint).frame(width: 6, height: 6) - Text(runtimeLabel) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(runtimeTint.opacity(0.06), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(runtimeTint.opacity(0.22), lineWidth: 0.5) - ) } - .menuStyle(.borderlessButton) - .buttonStyle(.plain) - .accessibilityLabel("Access mode: \(runtimeLabel). Tap to change.") - } - - Button(action: onOpenMentions) { - Image(systemName: "at") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) - .background(ADEColor.surfaceBackground.opacity(0.7), in: Circle()) - .glassEffect() - .overlay(Circle().stroke(ADEColor.glassBorder, lineWidth: 0.6)) + .padding(.trailing, 4) } - .buttonStyle(.plain) - .accessibilityLabel("Insert @ mention") - - Button(action: onOpenSlash) { - Text("/") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) - .background(ADEColor.surfaceBackground.opacity(0.7), in: Circle()) - .glassEffect() - .overlay(Circle().stroke(ADEColor.glassBorder, lineWidth: 0.6)) - } - .buttonStyle(.plain) - .accessibilityLabel("Insert slash command") - } - .padding(.trailing, 4) - } Button { let text = trimmedDraft @@ -477,7 +426,7 @@ private struct WorkNewChatComposerBar: View { .disabled(!canSend) .accessibilityLabel(canSend ? "Start chat" : "Enter a message to start") } - } + } .padding(.horizontal, 14) .padding(.vertical, 14) .background( diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index 50960cb36..f18fbb70c 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -26,7 +26,7 @@ struct WorkNewChatSheet: View { ] var selectedModel: AgentChatModelInfo? { - models.first(where: { $0.id == selectedModelId }) + models.first(where: { workModelIdsEquivalent($0.id, selectedModelId) || workModelIdsEquivalent($0.modelId, selectedModelId) }) } var trimmedInitialMessage: String { @@ -397,7 +397,10 @@ struct WorkNewChatSheet: View { let loadedModels = try await syncService.listChatModels(provider: requestedProvider) guard provider == requestedProvider else { return } models = loadedModels - if resetSelection || loadedModels.contains(where: { $0.id == selectedModelId }) == false { + let matchingSelection = loadedModels.first { + workModelIdsEquivalent($0.id, selectedModelId) || workModelIdsEquivalent($0.modelId, selectedModelId) + } + if resetSelection || matchingSelection == nil { if let preferred = loadedModels.first(where: \.isDefault) ?? loadedModels.first { selectedModelId = preferred.id selectedReasoningEffort = "" @@ -405,6 +408,9 @@ struct WorkNewChatSheet: View { selectedModelId = "" selectedReasoningEffort = "" } + } else if let matchingSelection, selectedModelId != matchingSelection.id { + selectedModelId = matchingSelection.id + selectedReasoningEffort = "" } errorMessage = nil } catch { diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index 6c6adbf58..c98653343 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -10,9 +10,9 @@ extension WorkSessionSettingsSheet { models = loadedModels let matchedModelId = - loadedModels.first(where: { $0.id == selectedModelId })?.id - ?? loadedModels.first(where: { $0.id == summary.modelId })?.id - ?? loadedModels.first(where: { $0.id == summary.model })?.id + loadedModels.first(where: { workModelIdsEquivalent($0.id, selectedModelId) || workModelIdsEquivalent($0.modelId, selectedModelId) })?.id + ?? loadedModels.first(where: { workModelIdsEquivalent($0.id, summary.modelId) || workModelIdsEquivalent($0.modelId, summary.modelId) })?.id + ?? loadedModels.first(where: { workModelIdsEquivalent($0.id, summary.model) || workModelIdsEquivalent($0.modelId, summary.model) })?.id ?? loadedModels.first(where: { $0.displayName == summary.model })?.id ?? loadedModels.first(where: \.isDefault)?.id ?? loadedModels.first?.id @@ -43,7 +43,7 @@ extension WorkSessionSettingsSheet { } let titleChanged = trimmedTitle != resolvedInitialTitle - let modelChanged = selectedModelId != resolvedInitialModelId + let modelChanged = !workModelIdsEquivalent(selectedModelId, resolvedInitialModelId) let normalizedReasoning = selectedReasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) let reasoningPayload = normalizedReasoning.isEmpty ? "" : normalizedReasoning let reasoningChanged = reasoningPayload != resolvedInitialReasoningEffort diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 588aab351..5bc2e4ab2 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -38,7 +38,7 @@ struct WorkSessionSettingsSheet: View { } var selectedModel: AgentChatModelInfo? { - models.first(where: { $0.id == selectedModelId }) + models.first(where: { workModelIdsEquivalent($0.id, selectedModelId) || workModelIdsEquivalent($0.modelId, selectedModelId) }) } var resolvedInitialModelId: String { diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 0a809d8f4..524c70493 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -1004,10 +1004,13 @@ func workTurnModelMetadataByTurn( /// Beautify a host-supplied model id into the label used on chips and turn /// separators. Mirrors the desktop composer's display: "Claude Sonnet 4.6", -/// "GPT-5.4-Codex", etc., so iOS and desktop read the same. +/// "GPT-5.4", etc., so iOS and desktop read the same. func prettyWorkChatModelName(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "Model" } + if let known = workKnownModelDisplayName(trimmed) { + return known + } switch trimmed.lowercased() { case "opus": return "Claude Opus 4.7" case "opus[1m]", "opus-1m": return "Claude Opus 4.7 1M" diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ac8fda3a5..be6831ba7 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1482,6 +1482,122 @@ final class ADETests: XCTestCase { reopened.close() } + @MainActor + func testSyncServicePersistsOutboundCursorAcrossRestart() throws { + let outboundCursorKey = "ade.sync.outboundSyncCursors" + let pendingOutboundChangesetsKey = "ade.sync.pendingOutboundChangesets" + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: outboundCursorKey) + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: outboundCursorKey) + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let baseURL = makeTemporaryDirectory() + let database = makeProjectLaneForeignKeyDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values ( + 'project-1', '/tmp/project-one', 'Project One', 'main', '2026-03-15T00:00:00.000Z', '2026-03-15T00:00:00.000Z' + ) + """) + + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + let initialCursor = service.outboundLocalDbVersionForTesting() + + try database.executeSqlForTesting(""" + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, parent_lane_id, status, created_at, archived_at + ) values ( + 'lane-restart', 'project-1', 'Restart proof', null, 'worktree', 'origin/main', 'feature/restart', '/tmp/restart', null, 'active', '2026-03-15T00:00:00.000Z', null + ) + """) + let pendingLocalVersion = database.currentDbVersion() + XCTAssertGreaterThan(pendingLocalVersion, initialCursor) + + database.close() + let databaseBeforeAck = makeProjectLaneForeignKeyDatabase(baseURL: baseURL) + let restartedBeforeAck = SyncService(database: databaseBeforeAck) + restartedBeforeAck.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + XCTAssertEqual(restartedBeforeAck.outboundLocalDbVersionForTesting(), initialCursor) + + restartedBeforeAck.advanceOutboundCursorForTesting(to: pendingLocalVersion) + databaseBeforeAck.close() + let databaseAfterAck = makeProjectLaneForeignKeyDatabase(baseURL: baseURL) + let restartedAfterAck = SyncService(database: databaseAfterAck) + restartedAfterAck.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + XCTAssertEqual(restartedAfterAck.outboundLocalDbVersionForTesting(), pendingLocalVersion) + + databaseAfterAck.close() + } + + @MainActor + func testSyncServicePreservesPendingOutboundChangesetAcrossProjectSwitch() throws { + let outboundCursorKey = "ade.sync.outboundSyncCursors" + let pendingOutboundChangesetsKey = "ade.sync.pendingOutboundChangesets" + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: outboundCursorKey) + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: outboundCursorKey) + UserDefaults.standard.removeObject(forKey: pendingOutboundChangesetsKey) + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let baseURL = makeTemporaryDirectory() + let database = makeProjectLaneForeignKeyDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-03-15T00:00:00.000Z', '2026-03-15T00:00:00.000Z'), + ('project-2', '/tmp/project-two', 'Project Two', 'main', '2026-03-15T00:00:00.000Z', '2026-03-15T00:00:00.000Z') + """) + + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + let initialCursor = service.outboundLocalDbVersionForTesting() + + try database.executeSqlForTesting(""" + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, parent_lane_id, status, created_at, archived_at + ) values ( + 'lane-switch', 'project-1', 'Switch proof', null, 'worktree', 'origin/main', 'feature/switch', '/tmp/switch', null, 'active', '2026-03-15T00:00:00.000Z', null + ) + """) + let pendingLocalVersion = database.currentDbVersion() + XCTAssertGreaterThan(pendingLocalVersion, initialCursor) + + service.setActiveProjectForTesting(projectId: "project-2", rootPath: "/tmp/project-two") + XCTAssertEqual(service.outboundLocalDbVersionForTesting(), pendingLocalVersion) + + service.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + XCTAssertEqual(service.outboundLocalDbVersionForTesting(), initialCursor) + + service.advanceOutboundCursorForTesting(to: pendingLocalVersion) + database.close() + let databaseAfterAck = makeProjectLaneForeignKeyDatabase(baseURL: baseURL) + let restartedAfterAck = SyncService(database: databaseAfterAck) + restartedAfterAck.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-one") + XCTAssertEqual(restartedAfterAck.outboundLocalDbVersionForTesting(), pendingLocalVersion) + + databaseAfterAck.close() + } + func testDatabaseExportAndApplyChangesRoundTrip() throws { let source = makeDatabase(baseURL: makeTemporaryDirectory()) let target = makeDatabase(baseURL: makeTemporaryDirectory()) @@ -1511,6 +1627,25 @@ final class ADETests: XCTestCase { target.close() } + func testSyncChangesetBatchPayloadDecodesLegacyBatchWithoutBatchId() throws { + let data = """ + { + "reason": "relay", + "fromDbVersion": 12, + "toDbVersion": 14, + "changes": [] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(SyncChangesetBatchPayload.self, from: data) + + XCTAssertEqual(decoded.batchId, "legacy:12:14:0:empty") + XCTAssertEqual(decoded.reason, "relay") + XCTAssertEqual(decoded.fromDbVersion, 12) + XCTAssertEqual(decoded.toDbVersion, 14) + XCTAssertTrue(decoded.changes.isEmpty) + } + func testDatabaseAppliesPackedTextPrimaryKeysFromDesktopChanges() throws { let database = makeDatabase(baseURL: makeTemporaryDirectory()) XCTAssertNil(database.initializationError) @@ -2872,6 +3007,39 @@ final class ADETests: XCTestCase { } } + @MainActor + func testFireAndForgetRemoteCommandQueuesWithStableCommandIdWhenOffline() async throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + let pendingOperationsKey = "ade.sync.pendingOperations" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + defer { + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + } + + let descriptors = [ + SyncRemoteCommandDescriptor( + action: "chat.approve", + policy: SyncRemoteCommandPolicy(viewerAllowed: true, requiresApproval: nil, localOnly: nil, queueable: true) + ), + ] + UserDefaults.standard.set(try JSONEncoder().encode(descriptors), forKey: remoteCommandDescriptorsKey) + + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + await service.sendRemoteCommand(.approveSession, payload: [ + "sessionId": "session-1", + "itemId": "approval-1", + ]) + + let queued = service.pendingOperationsForTesting() + XCTAssertEqual(service.pendingOperationCount, 1) + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.kind, "command") + XCTAssertEqual(queued.first?.action, "chat.approve") + XCTAssertTrue(queued.first?.id.hasPrefix("ios-") == true) + } + func testPrActionAvailabilityMatchesDesktopBaseline() { let open = PrActionAvailability(prState: "open") XCTAssertTrue(open.showsMerge) @@ -4856,12 +5024,12 @@ final class ADETests: XCTestCase { let groups = workModelCatalogGroups(currentModelId: "", currentProvider: "codex") let codexGroup = groups.first(where: { $0.key == "codex" }) let openAIProvider = codexGroup?.providers.first(where: { $0.key == "openai" }) - let gpt55 = openAIProvider?.models.first(where: { $0.id == "gpt-5.5-codex" }) + let gpt55 = openAIProvider?.models.first(where: { $0.id == "gpt-5.5" }) XCTAssertEqual(gpt55?.displayName, "GPT-5.5") XCTAssertEqual(gpt55?.tier, .flagship) - XCTAssertNotNil(ADEColor.modelBrand(for: "gpt-5.5-codex")) - XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.5-codex"), ["low", "medium", "high", "xhigh"]) + XCTAssertNotNil(ADEColor.modelBrand(for: "gpt-5.5")) + XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.5"), ["low", "medium", "high", "xhigh"]) } func testDynamicWorkModelCatalogBuildsFromLiveHostModels() { @@ -4869,14 +5037,27 @@ final class ADETests: XCTestCase { availableModelsByProvider: [ "codex": [ AgentChatModelInfo( - id: "gpt-5.5-codex", + id: "gpt-5.5", displayName: "GPT-5.5", description: "Latest Codex model", isDefault: true, reasoningEfforts: nil, maxThinkingTokens: nil, - modelId: nil, - family: nil, + modelId: "openai/gpt-5.5", + family: "openai", + supportsReasoning: true, + supportsTools: true, + color: nil + ), + AgentChatModelInfo( + id: "gpt-5.4", + displayName: "GPT-5.4", + description: nil, + isDefault: false, + reasoningEfforts: nil, + maxThinkingTokens: nil, + modelId: "openai/gpt-5.4", + family: "openai", supportsReasoning: true, supportsTools: true, color: nil @@ -4917,14 +5098,50 @@ final class ADETests: XCTestCase { let codexGroup = groups.first(where: { $0.key == "codex" }) let codexOpenAI = codexGroup?.providers.first(where: { $0.key == "openai" }) - XCTAssertEqual(codexOpenAI?.models.first?.id, "gpt-5.5-codex") - XCTAssertEqual(codexOpenAI?.models.first?.tagline, "Flagship · 400K context") + XCTAssertEqual(codexOpenAI?.models.map(\.id), ["gpt-5.5", "gpt-5.4"]) + XCTAssertEqual(codexOpenAI?.models.first?.tagline, "Flagship · 1M context") + XCTAssertEqual(codexOpenAI?.models.first?.displayName, "GPT-5.5") let cursorGroup = groups.first(where: { $0.key == "cursor" }) XCTAssertEqual(cursorGroup?.providers.map(\.key), ["anthropic", "cursor"]) XCTAssertEqual(cursorGroup?.providers.first?.models.first?.provider, "claude") } + func testWorkModelCatalogTreatsCodexRuntimeAndRegistryIdsAsSameModel() { + let groups = workModelCatalogGroups( + availableModelsByProvider: [ + "codex": [ + AgentChatModelInfo( + id: "gpt-5.5", + displayName: "GPT-5.5", + description: nil, + isDefault: true, + reasoningEfforts: nil, + maxThinkingTokens: nil, + modelId: "openai/gpt-5.5", + family: "openai", + supportsReasoning: true, + supportsTools: true, + color: nil + ), + ], + ], + currentModelId: "openai/gpt-5.5", + currentProvider: "codex" + ) + + let codexOpenAI = groups + .first(where: { $0.key == "codex" })? + .providers + .first(where: { $0.key == "openai" }) + + XCTAssertEqual(codexOpenAI?.models.map(\.id), ["gpt-5.5"]) + XCTAssertTrue(workModelIdsEquivalent("gpt-5.5", "openai/gpt-5.5")) + XCTAssertTrue(workModelIdsEquivalent("openai/gpt-5.5", "gpt-5.5")) + XCTAssertEqual(workKnownModelDisplayName("openai/gpt-5.5"), "GPT-5.5") + XCTAssertEqual(prettyWorkChatModelName("openai/gpt-5.5"), "GPT-5.5") + } + func testExtractWorkNavigationTargetsFindsFilePathsAndPullRequestNumbers() { let targets = extractWorkNavigationTargets( from: #"Updated apps/ios/ADE/Views/WorkTabView.swift and docs/plan.md before opening PR #145. See src/main.ts too."# @@ -5190,7 +5407,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:01:03.000Z", sequence: 6, - event: .done(status: "completed", summary: "Completed\ngpt-5.4-mini", usage: nil, turnId: "turn-2", model: "gpt-5.4-mini", modelId: "openai/gpt-5.4-mini-codex") + event: .done(status: "completed", summary: "Completed\ngpt-5.4-mini", usage: nil, turnId: "turn-2", model: "gpt-5.4-mini", modelId: "openai/gpt-5.4-mini") ), ] let timeline = buildWorkTimeline( @@ -5208,7 +5425,7 @@ final class ADETests: XCTestCase { return message } XCTAssertEqual(assistantMessages.map(\.turnProvider), ["claude", "codex"]) - XCTAssertEqual(assistantMessages.map(\.turnModelId), ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4-mini-codex"]) + XCTAssertEqual(assistantMessages.map(\.turnModelId), ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4-mini"]) let separated = injectWorkTurnSeparators( into: timeline, diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index c34aac819..18786a636 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -143,12 +143,14 @@ Two independent controls: `config-toml`, ADE defers both controls to the project's `.codex/config.toml`. -The chat adapter now rehydrates the effective approval/sandbox/reasoning -tuple from the Codex app-server response: every `thread/start` and -`thread/resume` call passes `{ model, cwd, ...codexPolicyArgs, -persistExtendedHistory: true }` (no redundant `reasoningEffort`) and the -return envelope is consumed by `applyCodexEffectiveThreadState`, which -normalizes `approvalPolicy`, `sandbox` (including the camel-case +The chat adapter translates ADE's persisted kebab-case approval/sandbox +values into the Codex app-server wire format at the JSON-RPC boundary: +`on-request` -> `onRequest`, `untrusted` -> `unlessTrusted`, +`on-failure` -> `onFailure`, and `workspace-write` -> `workspaceWrite`. +Every `thread/start` and `thread/resume` call passes `{ model, cwd, +reasoningEffort, ...codexPolicyArgs, persistExtendedHistory: true }`. +The return envelope is consumed by `applyCodexEffectiveThreadState`, +which normalizes `approvalPolicy`, `sandbox` (including the camel-case aliases `readOnly` / `workspaceWrite` / `dangerFullAccess` that the server emits), and `reasoningEffort`. That snapshot becomes the session state, so the picker chips always show what the runtime actually @@ -156,7 +158,7 @@ applied. On resume, the persisted chat state is re-written after normalization instead of being re-copied from the on-disk file — the server's reading of `.codex/config.toml` wins over a stale persisted pair. Turns use the Codex-native `effort` key -(`turn/start({ threadId, input, effort? })`) instead of the legacy +(`turn/start({ threadId, input, effort? })`) instead of the lifecycle `reasoningEffort` name. Default Codex chats map to the "Default permissions" preset diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 93f4b3530..ee02f0e7f 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -68,8 +68,9 @@ only when they join the same sync cluster. ┌────────────────────────────────────────────────────────────────┐ │ Sync transport (ws) │ │ - SyncEnvelope: hello, pairing, changeset_batch, │ -│ heartbeat, file_request/response, terminal_*, chat_*, │ -│ brain_status, project_catalog/project_switch, │ +│ changeset_ack, heartbeat, file_request/response, │ +│ terminal_*, chat_*, brain_status, │ +│ project_catalog/project_switch, │ │ command / command_ack / command_result │ │ - JSON payloads; gzip+base64 above threshold (4KB default) │ └────────────────────────────────────────────────────────────────┘ @@ -308,8 +309,8 @@ Envelopes are JSON with fields: { version: 1, type: "hello" | "hello_ok" | "hello_error" | "pairing_request" | - "pairing_result" | "changeset_batch" | "heartbeat" | - "file_request" | "file_response" | + "pairing_result" | "changeset_batch" | "changeset_ack" | + "heartbeat" | "file_request" | "file_response" | "terminal_subscribe" | "terminal_unsubscribe" | "terminal_snapshot" | "terminal_data" | "terminal_exit" | "terminal_input" | "terminal_resize" | @@ -338,6 +339,19 @@ Heartbeat interval is 30 seconds; a peer only gets closed after immediately). Reconnection resumes from the last-known `db_version` so no changesets are lost. +`changeset_batch` envelopes carry a `batchId`; the receiver replies +with a `changeset_ack` once `applyChanges` commits (or with an error +code on failure). The host keeps the batch in `pendingChangesetBatch` +until the ack lands, retransmitting on timeout so a dropped wifi blip +cannot lose a batch. `pendingChangesetPeerCount` is surfaced through +`brain_status` for diagnostics. + +Mobile-originated `command` envelopes are deduplicated through a +short-lived `mobileCommandResultCache` (TTL 30 minutes, 512 entries) +plus a persisted journal, so a phone that retries the same +`commandId` after a reconnect receives the cached `command_ack` / +`command_result` instead of double-executing the action. + ### Sub-protocols at a glance | Sub-protocol | Purpose | Used by | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 53e836708..ad257314b 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -241,6 +241,7 @@ Implemented envelope types on iOS: | `project_catalog_request` / `project_catalog` | Phone → host / host → phone | Refresh recent/available desktop projects | | `project_switch_request` / `project_switch_result` | Phone → host / host → phone | Prepare a sync connection for a selected desktop project | | `changeset_batch` | Bidirectional | cr-sqlite changeset batch | +| `changeset_ack` | Bidirectional | Per-batch apply confirmation (or error code); the sender retransmits on timeout | | `command` | Phone → host | Execution request | | `command_ack` | Host → phone | Command receipt | | `command_result` | Host → phone | Execution result or error | @@ -258,7 +259,10 @@ turns a raw response dict into either the `result` value or throws an ### Offline behavior - All synced state is available offline from the local DB. -- Execution commands queue locally and replay on reconnect. +- Execution commands queue locally and replay on reconnect. The host + deduplicates retried commands by `commandId` through a TTL'd cache + + persisted journal, so a replay returns the cached + `command_ack` / `command_result` instead of running twice. - UI shows "pending sync" indicators for queued actions. ### Timeouts