authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 10:15:20-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log50f35351a2c96c022de1f4329ce92d1f0b60a990
tree560caccaac228e12073a927d374439c4a09d95d8
parentc7460bde16bab5f4570f4db772f37c3538acc630

Fix Claude model fallback in web store sync

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 113 insertions(+), 3 deletions(-)

apps/web/src/store.test.ts+82-2
......@@ -1,7 +1,7 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
1import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { markThreadUnread, type AppState } from "./store";
4import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
55import type { Thread } from "./types";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -42,6 +42,46 @@ function makeState(thread: Thread): AppState {
4242 };
4343}
4444
45function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
46 return {
47 id: ThreadId.makeUnsafe("thread-1"),
48 projectId: ProjectId.makeUnsafe("project-1"),
49 title: "Thread",
50 model: "gpt-5.3-codex",
51 branch: null,
52 worktreePath: null,
53 latestTurn: null,
54 createdAt: "2026-02-27T00:00:00.000Z",
55 updatedAt: "2026-02-27T00:00:00.000Z",
56 deletedAt: null,
57 messages: [],
58 activities: [],
59 checkpoints: [],
60 session: null,
61 ...overrides,
62 } satisfies OrchestrationReadModel["threads"][number];
63}
64
65function makeReadModel(thread: OrchestrationReadModel["threads"][number]): OrchestrationReadModel {
66 return {
67 snapshotSequence: 1,
68 updatedAt: "2026-02-27T00:00:00.000Z",
69 projects: [
70 {
71 id: ProjectId.makeUnsafe("project-1"),
72 title: "Project",
73 workspaceRoot: "/tmp/project",
74 defaultModel: "gpt-5.3-codex",
75 createdAt: "2026-02-27T00:00:00.000Z",
76 updatedAt: "2026-02-27T00:00:00.000Z",
77 deletedAt: null,
78 scripts: [],
79 },
80 ],
81 threads: [thread],
82 };
83}
84
4585describe("store pure functions", () => {
4686 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
4787 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
......@@ -82,3 +122,43 @@ describe("store pure functions", () => {
82122 expect(next).toEqual(initialState);
83123 });
84124});
125
126describe("store read model sync", () => {
127 it("preserves claude model slugs without an active session", () => {
128 const initialState = makeState(makeThread());
129 const readModel = makeReadModel(
130 makeReadModelThread({
131 model: "claude-opus-4-6",
132 }),
133 );
134
135 const next = syncServerReadModel(initialState, readModel);
136
137 expect(next.threads[0]?.model).toBe("claude-opus-4-6");
138 });
139
140 it("resolves claude aliases when session provider is claudeCode", () => {
141 const initialState = makeState(makeThread());
142 const readModel = makeReadModel(
143 makeReadModelThread({
144 model: "sonnet",
145 session: {
146 threadId: ThreadId.makeUnsafe("thread-1"),
147 status: "ready",
148 providerName: "claudeCode",
149 providerSessionId: null,
150 providerThreadId: null,
151 approvalPolicy: "on-request",
152 sandboxMode: "workspace-write",
153 activeTurnId: null,
154 lastError: null,
155 updatedAt: "2026-02-27T00:00:00.000Z",
156 },
157 }),
158 );
159
160 const next = syncServerReadModel(initialState, readModel);
161
162 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
163 });
164});
apps/web/src/store.ts+31-1
......@@ -1,11 +1,14 @@
11import { Fragment, type ReactNode, createElement, useEffect } from "react";
22import {
33 DEFAULT_MODEL,
4 getModelOptions,
5 normalizeModelSlug,
46 ProviderSessionId,
57 ThreadId,
68 type OrchestrationReadModel,
79 type OrchestrationSessionStatus,
810 resolveModelSlug,
11 resolveModelSlugForProvider,
912} from "@t3tools/contracts";
1013import { create } from "zustand";
1114import {
......@@ -157,6 +160,27 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
157160 return providerName === "claudeCode" ? "claudeCode" : "codex";
158161}
159162
163const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));
164const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug));
165
166function inferProviderForThreadModel(input: {
167 readonly model: string;
168 readonly sessionProviderName: string | null;
169}): "codex" | "claudeCode" {
170 if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode") {
171 return input.sessionProviderName;
172 }
173 const normalizedClaude = normalizeModelSlug(input.model, "claudeCode");
174 if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) {
175 return "claudeCode";
176 }
177 const normalizedCodex = normalizeModelSlug(input.model, "codex");
178 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
179 return "codex";
180 }
181 return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";
182}
183
160184function resolveWsHttpOrigin(): string {
161185 if (typeof window === "undefined") return "";
162186 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
......@@ -215,7 +239,13 @@ export function syncServerReadModel(
215239 codexThreadId: thread.session?.providerThreadId ?? null,
216240 projectId: thread.projectId,
217241 title: thread.title,
218 model: resolveModelSlug(thread.model),
242 model: resolveModelSlugForProvider(
243 inferProviderForThreadModel({
244 model: thread.model,
245 sessionProviderName: thread.session?.providerName ?? null,
246 }),
247 thread.model,
248 ),
219249 session: thread.session
220250 ? {
221251 sessionId: