diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2b29dd8ff8590ab7d29e9000a269bcdd938d6379..35cd07ca829f067f57d061f10fe7d43e161bd61e 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,7 +1,7 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, type AppState } from "./store"; +import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; import type { Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -42,6 +42,46 @@ function makeState(thread: Thread): AppState { }; } +function makeReadModelThread(overrides: Partial) { + return { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5.3-codex", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + messages: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + } satisfies OrchestrationReadModel["threads"][number]; +} + +function makeReadModel(thread: OrchestrationReadModel["threads"][number]): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: "2026-02-27T00:00:00.000Z", + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: "gpt-5.3-codex", + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + scripts: [], + }, + ], + threads: [thread], + }; +} + describe("store pure functions", () => { it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; @@ -82,3 +122,43 @@ describe("store pure functions", () => { expect(next).toEqual(initialState); }); }); + +describe("store read model sync", () => { + it("preserves claude model slugs without an active session", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "claude-opus-4-6", + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); + }); + + it("resolves claude aliases when session provider is claudeCode", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "sonnet", + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + providerSessionId: null, + providerThreadId: null, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + activeTurnId: null, + lastError: null, + updatedAt: "2026-02-27T00:00:00.000Z", + }, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e761bc30d472a89e01962874bc21b49c4cce95f6..95564375eb271ec1732cda164608f116a67c80b8 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,11 +1,14 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { DEFAULT_MODEL, + getModelOptions, + normalizeModelSlug, ProviderSessionId, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, resolveModelSlug, + resolveModelSlugForProvider, } from "@t3tools/contracts"; import { create } from "zustand"; import { @@ -157,6 +160,27 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { return providerName === "claudeCode" ? "claudeCode" : "codex"; } +const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); + +function inferProviderForThreadModel(input: { + readonly model: string; + readonly sessionProviderName: string | null; +}): "codex" | "claudeCode" { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode") { + return input.sessionProviderName; + } + const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); + if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { + return "claudeCode"; + } + const normalizedCodex = normalizeModelSlug(input.model, "codex"); + if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { + return "codex"; + } + return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex"; +} + function resolveWsHttpOrigin(): string { if (typeof window === "undefined") return ""; const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); @@ -215,7 +239,13 @@ export function syncServerReadModel( codexThreadId: thread.session?.providerThreadId ?? null, projectId: thread.projectId, title: thread.title, - model: resolveModelSlug(thread.model), + model: resolveModelSlugForProvider( + inferProviderForThreadModel({ + model: thread.model, + sessionProviderName: thread.session?.providerName ?? null, + }), + thread.model, + ), session: thread.session ? { sessionId: