| author | |
| committer | |
| log | 62cf4617594c2a0d8c9c5dc4bbc2012d0ead802d |
| tree | 1bafc0486a2dd7fbaebe765f4783bd9a4e935aa7 |
| parent | 23c18fda7a969634a30888e36b2da45f6d66a83b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>17 files changed, 218 insertions(+), 50 deletions(-)
apps/mobile/src/features/threads/new-task-flow-provider.tsx+29-10| ... | @@ -58,6 +58,8 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; | ... | @@ -58,6 +58,8 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 58 | 58 | ||
| 59 | type WorkspaceMode = "local" | "worktree"; | 59 | type WorkspaceMode = "local" | "worktree"; |
| 60 | 60 | ||
| 61 | const EMPTY_BRANCH_REFS: ReadonlyArray<VcsRef> = []; | ||
| 62 | |||
| 61 | function pendingTaskDraftKey(messageId: string): string { | 63 | function pendingTaskDraftKey(messageId: string): string { |
| 62 | return `pending-task:${messageId}`; | 64 | return `pending-task:${messageId}`; |
| 63 | } | 65 | } |
| ... | @@ -348,7 +350,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -348,7 +350,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 348 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; | 350 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; |
| 349 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; | 351 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 350 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; | 352 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| 351 | const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; | 353 | // Keep the user's explicit choice separate from the resolved display value: |
| 354 | // only the explicit flag is ever written back to the draft, so the resolved | ||
| 355 | // value keeps tracking the server setting when the config loads late. | ||
| 356 | const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; | ||
| 357 | const startFromOrigin = | ||
| 358 | draftStartFromOrigin ?? | ||
| 359 | selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? | ||
| 360 | true; | ||
| 352 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 361 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 353 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | 362 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 354 | 363 | ||
| ... | @@ -368,6 +377,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -368,6 +377,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 368 | const selectedModel = | 377 | const selectedModel = |
| 369 | selectedProjectDraft.modelSelection ?? | 378 | selectedProjectDraft.modelSelection ?? |
| 370 | selectedProject?.defaultModelSelection ?? | 379 | selectedProject?.defaultModelSelection ?? |
| 380 | modelOptions.find((option) => option.isDefault)?.selection ?? | ||
| 371 | modelOptions[0]?.selection ?? | 381 | modelOptions[0]?.selection ?? |
| 372 | null; | 382 | null; |
| 373 | const selectedModelKey = selectedModel | 383 | const selectedModelKey = selectedModel |
| ... | @@ -475,13 +485,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -475,13 +485,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 475 | ); | 485 | ); |
| 476 | const branchState = useBranches(branchTarget); | 486 | const branchState = useBranches(branchTarget); |
| 477 | const branchesLoading = branchState.isPending; | 487 | const branchesLoading = branchState.isPending; |
| 488 | const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; | ||
| 478 | const availableBranches = useMemo( | 489 | const availableBranches = useMemo( |
| 479 | () => | 490 | () => |
| 480 | pipe( | 491 | pipe( |
| 481 | branchState.data?.refs ?? [], | 492 | allBranchRefs, |
| 482 | Arr.filter((branch) => !branch.isRemote), | 493 | Arr.filter((branch) => !branch.isRemote), |
| 483 | ), | 494 | ), |
| 484 | [branchState.data?.refs], | 495 | [allBranchRefs], |
| 485 | ); | 496 | ); |
| 486 | 497 | ||
| 487 | const filteredBranches = useMemo(() => { | 498 | const filteredBranches = useMemo(() => { |
| ... | @@ -543,11 +554,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -543,11 +554,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 543 | mode, | 554 | mode, |
| 544 | branch: selectedBranchName, | 555 | branch: selectedBranchName, |
| 545 | worktreePath: selectedWorktreePath, | 556 | worktreePath: selectedWorktreePath, |
| 546 | startFromOrigin, | 557 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), |
| 547 | }, | 558 | }, |
| 548 | }); | 559 | }); |
| 549 | }, | 560 | }, |
| 550 | [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], | 561 | [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], |
| 551 | ); | 562 | ); |
| 552 | 563 | ||
| 553 | const selectBranch = useCallback( | 564 | const selectBranch = useCallback( |
| ... | @@ -560,11 +571,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -560,11 +571,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 560 | mode: workspaceMode, | 571 | mode: workspaceMode, |
| 561 | branch: branch.name, | 572 | branch: branch.name, |
| 562 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), | 573 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), |
| 563 | startFromOrigin, | 574 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), |
| 564 | }, | 575 | }, |
| 565 | }); | 576 | }); |
| 566 | }, | 577 | }, |
| 567 | [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], | 578 | [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], |
| 568 | ); | 579 | ); |
| 569 | 580 | ||
| 570 | const setStartFromOrigin = useCallback( | 581 | const setStartFromOrigin = useCallback( |
| ... | @@ -597,14 +608,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -597,14 +608,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 597 | if (workspaceMode !== "worktree" || selectedBranchName !== null) { | 608 | if (workspaceMode !== "worktree" || selectedBranchName !== null) { |
| 598 | return; | 609 | return; |
| 599 | } | 610 | } |
| 611 | // The default may only exist as origin/<default> (isRemote), which | ||
| 612 | // availableBranches filters out — search the unfiltered refs for it. | ||
| 600 | const preferredBranch = | 613 | const preferredBranch = |
| 614 | allBranchRefs.find((branch) => branch.isDefault) ?? | ||
| 601 | availableBranches.find((branch) => branch.current) ?? | 615 | availableBranches.find((branch) => branch.current) ?? |
| 602 | availableBranches.find((branch) => branch.isDefault) ?? | ||
| 603 | null; | 616 | null; |
| 604 | if (preferredBranch) { | 617 | if (preferredBranch) { |
| 605 | selectBranch(preferredBranch); | 618 | selectBranch(preferredBranch); |
| 606 | } | 619 | } |
| 607 | }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); | 620 | }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); |
| 608 | 621 | ||
| 609 | const setRuntimeMode = useCallback( | 622 | const setRuntimeMode = useCallback( |
| 610 | (value: RuntimeMode) => { | 623 | (value: RuntimeMode) => { |
| ... | @@ -696,7 +709,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -696,7 +709,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 696 | workspaceMode: mode, | 709 | workspaceMode: mode, |
| 697 | branch: workspaceSelection?.branch ?? null, | 710 | branch: workspaceSelection?.branch ?? null, |
| 698 | worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), | 711 | worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), |
| 699 | ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), | 712 | // The draft only carries the flag when the user touched it; fall |
| 713 | // back to the resolved default (server settings) so queued tasks | ||
| 714 | // drain with the same origin mode the composer displayed. | ||
| 715 | ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) | ||
| 716 | ? { startFromOrigin: true } | ||
| 717 | : {}), | ||
| 700 | }, | 718 | }, |
| 701 | createdAt: metadata.createdAt, | 719 | createdAt: metadata.createdAt, |
| 702 | }; | 720 | }; |
| ... | @@ -707,6 +725,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -707,6 +725,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 707 | selectedModel, | 725 | selectedModel, |
| 708 | selectedProject, | 726 | selectedProject, |
| 709 | selectedProjectDraftKey, | 727 | selectedProjectDraftKey, |
| 728 | startFromOrigin, | ||
| 710 | ], | 729 | ], |
| 711 | ); | 730 | ); |
| 712 | 731 |
apps/mobile/src/lib/modelOptions.ts+3| ... | @@ -15,6 +15,7 @@ export type ModelOption = { | ... | @@ -15,6 +15,7 @@ export type ModelOption = { |
| 15 | readonly providerKey: string; | 15 | readonly providerKey: string; |
| 16 | readonly providerLabel: string; | 16 | readonly providerLabel: string; |
| 17 | readonly providerDriver: string; | 17 | readonly providerDriver: string; |
| 18 | readonly isDefault: boolean; | ||
| 18 | readonly capabilities: ModelCapabilities | null; | 19 | readonly capabilities: ModelCapabilities | null; |
| 19 | readonly selection: ModelSelection; | 20 | readonly selection: ModelSelection; |
| 20 | }; | 21 | }; |
| ... | @@ -78,6 +79,7 @@ export function buildModelOptions( | ... | @@ -78,6 +79,7 @@ export function buildModelOptions( |
| 78 | providerKey: provider.instanceId, | 79 | providerKey: provider.instanceId, |
| 79 | providerLabel, | 80 | providerLabel, |
| 80 | providerDriver: provider.driver, | 81 | providerDriver: provider.driver, |
| 82 | isDefault: model.isDefault === true, | ||
| 81 | capabilities: model.capabilities, | 83 | capabilities: model.capabilities, |
| 82 | selection: normalizeSelectionOptions( | 84 | selection: normalizeSelectionOptions( |
| 83 | { | 85 | { |
| ... | @@ -107,6 +109,7 @@ export function buildModelOptions( | ... | @@ -107,6 +109,7 @@ export function buildModelOptions( |
| 107 | providerKey: fallbackModelSelection.instanceId, | 109 | providerKey: fallbackModelSelection.instanceId, |
| 108 | providerLabel, | 110 | providerLabel, |
| 109 | providerDriver: fallbackModelSelection.instanceId, | 111 | providerDriver: fallbackModelSelection.instanceId, |
| 112 | isDefault: false, | ||
| 110 | capabilities: null, | 113 | capabilities: null, |
| 111 | selection: fallbackModelSelection, | 114 | selection: fallbackModelSelection, |
| 112 | }); | 115 | }); |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+6-5| ... | @@ -3045,7 +3045,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3045,7 +3045,7 @@ describe("ClaudeAdapterLive", () => { |
| 3045 | attachments: [], | 3045 | attachments: [], |
| 3046 | }); | 3046 | }); |
| 3047 | 3047 | ||
| 3048 | assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); | 3048 | assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); |
| 3049 | }).pipe( | 3049 | }).pipe( |
| 3050 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | 3050 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 3051 | Effect.provide(harness.layer), | 3051 | Effect.provide(harness.layer), |
| ... | @@ -3143,10 +3143,11 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3143,10 +3143,11 @@ describe("ClaudeAdapterLive", () => { |
| 3143 | yield* adapter.sendTurn({ | 3143 | yield* adapter.sendTurn({ |
| 3144 | threadId: session.threadId, | 3144 | threadId: session.threadId, |
| 3145 | input: "hello again", | 3145 | input: "hello again", |
| 3146 | modelSelection: { | 3146 | modelSelection: createModelSelection( |
| 3147 | instanceId: ProviderInstanceId.make("claudeAgent"), | 3147 | ProviderInstanceId.make("claudeAgent"), |
| 3148 | model: "claude-opus-4-6", | 3148 | "claude-opus-4-6", |
| 3149 | }, | 3149 | [{ id: "contextWindow", value: "200k" }], |
| 3150 | ), | ||
| 3150 | attachments: [], | 3151 | attachments: [], |
| 3151 | }); | 3152 | }); |
| 3152 | 3153 |
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-14| ... | @@ -77,6 +77,7 @@ import { | ... | @@ -77,6 +77,7 @@ import { |
| 77 | isClaudeUltracodeEffort, | 77 | isClaudeUltracodeEffort, |
| 78 | normalizeClaudeCliEffort, | 78 | normalizeClaudeCliEffort, |
| 79 | resolveClaudeApiModelId, | 79 | resolveClaudeApiModelId, |
| 80 | resolveClaudeContextWindow, | ||
| 80 | resolveClaudeEffort, | 81 | resolveClaudeEffort, |
| 81 | } from "./ClaudeProvider.ts"; | 82 | } from "./ClaudeProvider.ts"; |
| 82 | import { | 83 | import { |
| ... | @@ -341,24 +342,18 @@ function selectedClaudeContextWindow( | ... | @@ -341,24 +342,18 @@ function selectedClaudeContextWindow( |
| 341 | switch (modelSelection?.model) { | 342 | switch (modelSelection?.model) { |
| 342 | case "claude-opus-4-8": | 343 | case "claude-opus-4-8": |
| 343 | case "claude-opus-4-7": | 344 | case "claude-opus-4-7": |
| 345 | // Always 1M at the API; these models expose no contextWindow option. | ||
| 344 | return 1_000_000; | 346 | return 1_000_000; |
| 345 | } | 347 | } |
| 346 | 348 | ||
| 347 | const optionValue = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); | 349 | switch (resolveClaudeContextWindow(modelSelection)) { |
| 348 | if (optionValue === "1m") { | 350 | case "1m": |
| 349 | return 1_000_000; | 351 | return 1_000_000; |
| 350 | } | 352 | case "200k": |
| 351 | if (optionValue === "200k") { | 353 | return 200_000; |
| 352 | return 200_000; | 354 | default: |
| 353 | } | 355 | return undefined; |
| 354 | const caps = getClaudeModelCapabilities(modelSelection?.model); | ||
| 355 | const hasContextWindowOption = getProviderOptionDescriptors({ caps }).some( | ||
| 356 | (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", | ||
| 357 | ); | ||
| 358 | if (hasContextWindowOption) { | ||
| 359 | return 200_000; | ||
| 360 | } | 356 | } |
| 361 | return undefined; | ||
| 362 | } | 357 | } |
| 363 | 358 | ||
| 364 | function finiteNonNegativeInteger(value: unknown): number | undefined { | 359 | function finiteNonNegativeInteger(value: unknown): number | undefined { |
apps/server/src/provider/Layers/ClaudeProvider.ts+21-5| ... | @@ -76,8 +76,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -76,8 +76,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 76 | id: "contextWindow", | 76 | id: "contextWindow", |
| 77 | label: "Context Window", | 77 | label: "Context Window", |
| 78 | options: [ | 78 | options: [ |
| 79 | { value: "200k", label: "200k", isDefault: true }, | 79 | { value: "200k", label: "200k" }, |
| 80 | { value: "1m", label: "1M" }, | 80 | { value: "1m", label: "1M", isDefault: true }, |
| 81 | ], | 81 | ], |
| 82 | }), | 82 | }), |
| 83 | ], | 83 | ], |
| ... | @@ -162,8 +162,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -162,8 +162,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 162 | id: "contextWindow", | 162 | id: "contextWindow", |
| 163 | label: "Context Window", | 163 | label: "Context Window", |
| 164 | options: [ | 164 | options: [ |
| 165 | { value: "200k", label: "200k", isDefault: true }, | 165 | { value: "200k", label: "200k" }, |
| 166 | { value: "1m", label: "1M" }, | 166 | { value: "1m", label: "1M", isDefault: true }, |
| 167 | ], | 167 | ], |
| 168 | }), | 168 | }), |
| 169 | ], | 169 | ], |
| ... | @@ -214,6 +214,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -214,6 +214,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 214 | buildSelectOptionDescriptor({ | 214 | buildSelectOptionDescriptor({ |
| 215 | id: "contextWindow", | 215 | id: "contextWindow", |
| 216 | label: "Context Window", | 216 | label: "Context Window", |
| 217 | // Sonnet is 200k-default in Claude Code (1M is opt-in there too). | ||
| 217 | options: [ | 218 | options: [ |
| 218 | { value: "200k", label: "200k", isDefault: true }, | 219 | { value: "200k", label: "200k", isDefault: true }, |
| 219 | { value: "1m", label: "1M" }, | 220 | { value: "1m", label: "1M" }, |
| ... | @@ -243,6 +244,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -243,6 +244,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 243 | buildSelectOptionDescriptor({ | 244 | buildSelectOptionDescriptor({ |
| 244 | id: "contextWindow", | 245 | id: "contextWindow", |
| 245 | label: "Context Window", | 246 | label: "Context Window", |
| 247 | // Sonnet is 200k-default in Claude Code (1M is opt-in there too). | ||
| 246 | options: [ | 248 | options: [ |
| 247 | { value: "200k", label: "200k", isDefault: true }, | 249 | { value: "200k", label: "200k", isDefault: true }, |
| 248 | { value: "1m", label: "1M" }, | 250 | { value: "1m", label: "1M" }, |
| ... | @@ -369,8 +371,22 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool | ... | @@ -369,8 +371,22 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool |
| 369 | return effort === "ultracode"; | 371 | return effort === "ultracode"; |
| 370 | } | 372 | } |
| 371 | 373 | ||
| 374 | export function resolveClaudeContextWindow( | ||
| 375 | modelSelection: ModelSelection | undefined, | ||
| 376 | ): string | undefined { | ||
| 377 | const caps = getClaudeModelCapabilities(modelSelection?.model); | ||
| 378 | const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); | ||
| 379 | const descriptors = getProviderOptionDescriptors({ | ||
| 380 | caps, | ||
| 381 | ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), | ||
| 382 | }); | ||
| 383 | const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); | ||
| 384 | const value = getProviderOptionCurrentValue(descriptor); | ||
| 385 | return typeof value === "string" ? value : undefined; | ||
| 386 | } | ||
| 387 | |||
| 372 | export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { | 388 | export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { |
| 373 | switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { | 389 | switch (resolveClaudeContextWindow(modelSelection)) { |
| 374 | case "1m": | 390 | case "1m": |
| 375 | return `${modelSelection.model}[1m]`; | 391 | return `${modelSelection.model}[1m]`; |
| 376 | default: | 392 | default: |
apps/server/src/provider/Layers/CodexProvider.test.ts+43-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { mapCodexModelCapabilities } from "./CodexProvider.ts"; | 3 | import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; |
| 4 | 4 | ||
| 5 | it("maps current Codex model capability fields", () => { | 5 | it("maps current Codex model capability fields", () => { |
| 6 | const capabilities = mapCodexModelCapabilities({ | 6 | const capabilities = mapCodexModelCapabilities({ |
| ... | @@ -102,3 +102,45 @@ it("uses standard routing when the catalog has no default service tier", () => { | ... | @@ -102,3 +102,45 @@ it("uses standard routing when the catalog has no default service tier", () => { |
| 102 | }, | 102 | }, |
| 103 | ]); | 103 | ]); |
| 104 | }); | 104 | }); |
| 105 | |||
| 106 | it("marks the most preferred available model as default", () => { | ||
| 107 | const models = applyPreferredCodexDefaultModel([ | ||
| 108 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 109 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 110 | ]); | ||
| 111 | |||
| 112 | assert.deepStrictEqual( | ||
| 113 | models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), | ||
| 114 | [ | ||
| 115 | { slug: "gpt-5.6-terra", isDefault: true }, | ||
| 116 | { slug: "gpt-5.4", isDefault: undefined }, | ||
| 117 | ], | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("prefers sol over terra when both are available", () => { | ||
| 122 | const models = applyPreferredCodexDefaultModel([ | ||
| 123 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 124 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 125 | ]); | ||
| 126 | |||
| 127 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("keeps Codex's own default when no preferred model is available", () => { | ||
| 131 | const models = applyPreferredCodexDefaultModel([ | ||
| 132 | { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, | ||
| 133 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 134 | ]); | ||
| 135 | |||
| 136 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("ignores custom models that shadow a preferred slug", () => { | ||
| 140 | const models = applyPreferredCodexDefaultModel([ | ||
| 141 | { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, | ||
| 142 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 143 | ]); | ||
| 144 | |||
| 145 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 146 | }); |
apps/server/src/provider/Layers/CodexProvider.ts+30-2| ... | @@ -22,7 +22,7 @@ import type { | ... | @@ -22,7 +22,7 @@ import type { |
| 22 | ServerProviderModel, | 22 | ServerProviderModel, |
| 23 | ServerProviderSkill, | 23 | ServerProviderSkill, |
| 24 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import { ServerSettingsError } from "@t3tools/contracts"; | 25 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; |
| 26 | 26 | ||
| 27 | import { createModelCapabilities } from "@t3tools/shared/model"; | 27 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 28 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 28 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| ... | @@ -189,10 +189,36 @@ function parseCodexModelListResponse( | ... | @@ -189,10 +189,36 @@ function parseCodexModelListResponse( |
| 189 | slug: model.model, | 189 | slug: model.model, |
| 190 | name: toDisplayName(model), | 190 | name: toDisplayName(model), |
| 191 | isCustom: false, | 191 | isCustom: false, |
| 192 | ...(model.isDefault ? { isDefault: true } : {}), | ||
| 192 | capabilities: mapCodexModelCapabilities(model), | 193 | capabilities: mapCodexModelCapabilities(model), |
| 193 | })); | 194 | })); |
| 194 | } | 195 | } |
| 195 | 196 | ||
| 197 | /** | ||
| 198 | * Prefer our own default-model ranking when one of the preferred slugs is in | ||
| 199 | * the live catalog; otherwise keep whatever Codex itself flagged as default. | ||
| 200 | */ | ||
| 201 | export function applyPreferredCodexDefaultModel( | ||
| 202 | models: ReadonlyArray<ServerProviderModel>, | ||
| 203 | ): ReadonlyArray<ServerProviderModel> { | ||
| 204 | const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => | ||
| 205 | models.some((model) => model.slug === slug && !model.isCustom), | ||
| 206 | ); | ||
| 207 | if (!preferredSlug) { | ||
| 208 | return models; | ||
| 209 | } | ||
| 210 | return models.map((model) => { | ||
| 211 | if (model.slug === preferredSlug) { | ||
| 212 | return model.isDefault ? model : { ...model, isDefault: true }; | ||
| 213 | } | ||
| 214 | if (!model.isDefault) { | ||
| 215 | return model; | ||
| 216 | } | ||
| 217 | const { isDefault: _isDefault, ...rest } = model; | ||
| 218 | return rest; | ||
| 219 | }); | ||
| 220 | } | ||
| 221 | |||
| 196 | function appendCustomCodexModels( | 222 | function appendCustomCodexModels( |
| 197 | models: ReadonlyArray<ServerProviderModel>, | 223 | models: ReadonlyArray<ServerProviderModel>, |
| 198 | customModels: ReadonlyArray<string>, | 224 | customModels: ReadonlyArray<string>, |
| ... | @@ -376,7 +402,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -376,7 +402,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 376 | return { | 402 | return { |
| 377 | account: accountResponse, | 403 | account: accountResponse, |
| 378 | version, | 404 | version, |
| 379 | models: appendCustomCodexModels(models, input.customModels ?? []), | 405 | models: applyPreferredCodexDefaultModel( |
| 406 | appendCustomCodexModels(models, input.customModels ?? []), | ||
| 407 | ), | ||
| 380 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), | 408 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), |
| 381 | } satisfies CodexAppServerProviderSnapshot; | 409 | } satisfies CodexAppServerProviderSnapshot; |
| 382 | }); | 410 | }); |
apps/server/src/vcs/GitVcsDriverCore.test.ts+20| ... | @@ -596,6 +596,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -596,6 +596,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 596 | }), | 596 | }), |
| 597 | ); | 597 | ); |
| 598 | 598 | ||
| 599 | it.effect("marks the origin default ref as default when no local copy exists", () => | ||
| 600 | Effect.gen(function* () { | ||
| 601 | const cwd = yield* makeTmpDir(); | ||
| 602 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); | ||
| 603 | const { initialBranch } = yield* initRepoWithCommit(cwd); | ||
| 604 | yield* git(remote, ["init", "--bare"]); | ||
| 605 | yield* git(cwd, ["remote", "add", "origin", remote]); | ||
| 606 | yield* git(cwd, ["push", "-u", "origin", initialBranch]); | ||
| 607 | yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); | ||
| 608 | yield* git(cwd, ["checkout", "-b", "feature/only-local"]); | ||
| 609 | yield* git(cwd, ["branch", "-D", initialBranch]); | ||
| 610 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 611 | |||
| 612 | const refs = yield* driver.listRefs({ cwd }); | ||
| 613 | const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`); | ||
| 614 | assert.equal(remoteDefault?.isRemote, true); | ||
| 615 | assert.equal(remoteDefault?.isDefault, true); | ||
| 616 | }), | ||
| 617 | ); | ||
| 618 | |||
| 599 | it.effect("creates, checks out, renames, and lists refs", () => | 619 | it.effect("creates, checks out, renames, and lists refs", () => |
| 600 | Effect.gen(function* () { | 620 | Effect.gen(function* () { |
| 601 | const cwd = yield* makeTmpDir(); | 621 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+14-2| ... | @@ -2217,7 +2217,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2217,7 +2217,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2217 | name: refName.name, | 2217 | name: refName.name, |
| 2218 | current: false, | 2218 | current: false, |
| 2219 | isRemote: true, | 2219 | isRemote: true, |
| 2220 | isDefault: false, | 2220 | // origin/HEAD's target is the repo default even when no local |
| 2221 | // copy of the default branch exists. | ||
| 2222 | isDefault: | ||
| 2223 | defaultBranch !== null && | ||
| 2224 | parsedRemoteRef?.remoteName === "origin" && | ||
| 2225 | parsedRemoteRef.branchName === defaultBranch, | ||
| 2221 | worktreePath: null, | 2226 | worktreePath: null, |
| 2222 | }; | 2227 | }; |
| 2223 | if (parsedRemoteRef) { | 2228 | if (parsedRemoteRef) { |
| ... | @@ -2232,9 +2237,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2232,9 +2237,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2232 | }) | 2237 | }) |
| 2233 | : []; | 2238 | : []; |
| 2234 | 2239 | ||
| 2235 | const allBranches = input.includeMatchingRemoteRefs | 2240 | const combinedBranches = input.includeMatchingRemoteRefs |
| 2236 | ? [...localBranches, ...remoteBranches] | 2241 | ? [...localBranches, ...remoteBranches] |
| 2237 | : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); | 2242 | : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); |
| 2243 | // Keep current/default refs on the first page even when the default | ||
| 2244 | // only exists as origin/<default> (remote refs sort after all locals). | ||
| 2245 | const allBranches = combinedBranches.toSorted((a, b) => { | ||
| 2246 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | ||
| 2247 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | ||
| 2248 | return aPriority - bPriority; | ||
| 2249 | }); | ||
| 2238 | const branchesForKind = | 2250 | const branchesForKind = |
| 2239 | input.refKind === "local" | 2251 | input.refKind === "local" |
| 2240 | ? allBranches.filter((ref) => !ref.isRemote) | 2252 | ? allBranches.filter((ref) => !ref.isRemote) |
apps/web/src/components/BranchToolbarBranchSelector.tsx+18-3| ... | @@ -422,17 +422,32 @@ export function BranchToolbarBranchSelector({ | ... | @@ -422,17 +422,32 @@ export function BranchToolbarBranchSelector({ |
| 422 | }); | 422 | }); |
| 423 | }; | 423 | }; |
| 424 | 424 | ||
| 425 | // Default the worktree base to the repo default branch (origin/HEAD), only | ||
| 426 | // falling back to the checked-out branch when no default is known. | ||
| 427 | const defaultBranchName = useMemo( | ||
| 428 | () => refs.find((refName) => refName.isDefault)?.name ?? null, | ||
| 429 | [refs], | ||
| 430 | ); | ||
| 431 | const worktreeBaseBranchCandidate = isInitialBranchesLoadPending | ||
| 432 | ? null | ||
| 433 | : (defaultBranchName ?? currentGitBranch); | ||
| 425 | useEffect(() => { | 434 | useEffect(() => { |
| 426 | if ( | 435 | if ( |
| 427 | effectiveEnvMode !== "worktree" || | 436 | effectiveEnvMode !== "worktree" || |
| 428 | activeWorktreePath || | 437 | activeWorktreePath || |
| 429 | activeThreadBranch || | 438 | activeThreadBranch || |
| 430 | !currentGitBranch | 439 | !worktreeBaseBranchCandidate |
| 431 | ) { | 440 | ) { |
| 432 | return; | 441 | return; |
| 433 | } | 442 | } |
| 434 | setThreadBranch(currentGitBranch, null); | 443 | setThreadBranch(worktreeBaseBranchCandidate, null); |
| 435 | }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); | 444 | }, [ |
| 445 | activeThreadBranch, | ||
| 446 | activeWorktreePath, | ||
| 447 | effectiveEnvMode, | ||
| 448 | setThreadBranch, | ||
| 449 | worktreeBaseBranchCandidate, | ||
| 450 | ]); | ||
| 436 | 451 | ||
| 437 | // --------------------------------------------------------------------------- | 452 | // --------------------------------------------------------------------------- |
| 438 | // Combobox / list plumbing | 453 | // Combobox / list plumbing |
apps/web/src/modelSelection.ts+4| ... | @@ -75,6 +75,7 @@ export interface AppModelOption { | ... | @@ -75,6 +75,7 @@ export interface AppModelOption { |
| 75 | shortName?: string; | 75 | shortName?: string; |
| 76 | subProvider?: string; | 76 | subProvider?: string; |
| 77 | isCustom: boolean; | 77 | isCustom: boolean; |
| 78 | isDefault?: boolean; | ||
| 78 | } | 79 | } |
| 79 | 80 | ||
| 80 | function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { | 81 | function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { |
| ... | @@ -85,6 +86,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti | ... | @@ -85,6 +86,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti |
| 85 | }; | 86 | }; |
| 86 | if (model.shortName) option.shortName = model.shortName; | 87 | if (model.shortName) option.shortName = model.shortName; |
| 87 | if (model.subProvider) option.subProvider = model.subProvider; | 88 | if (model.subProvider) option.subProvider = model.subProvider; |
| 89 | if (model.isDefault) option.isDefault = true; | ||
| 88 | return option; | 90 | return option; |
| 89 | } | 91 | } |
| 90 | 92 | ||
| ... | @@ -247,7 +249,9 @@ export function resolveAppModelSelectionForInstance( | ... | @@ -247,7 +249,9 @@ export function resolveAppModelSelectionForInstance( |
| 247 | const options = getAppModelOptionsForInstance(settings, entry); | 249 | const options = getAppModelOptionsForInstance(settings, entry); |
| 248 | return ( | 250 | return ( |
| 249 | resolveSelectableModel(entry.driverKind, selectedModel, options) ?? | 251 | resolveSelectableModel(entry.driverKind, selectedModel, options) ?? |
| 252 | options.find((option) => option.isDefault)?.slug ?? | ||
| 250 | options[0]?.slug ?? | 253 | options[0]?.slug ?? |
| 254 | entry.models.find((model) => model.isDefault)?.slug ?? | ||
| 251 | entry.models[0]?.slug ?? | 255 | entry.models[0]?.slug ?? |
| 252 | null | 256 | null |
| 253 | ); | 257 | ); |
apps/web/src/providerModels.ts+1| ... | @@ -92,6 +92,7 @@ export function getDefaultServerModel( | ... | @@ -92,6 +92,7 @@ export function getDefaultServerModel( |
| 92 | ): string { | 92 | ): string { |
| 93 | const models = getProviderModels(providers, provider); | 93 | const models = getProviderModels(providers, provider); |
| 94 | return ( | 94 | return ( |
| 95 | models.find((model) => model.isDefault && !model.isCustom)?.slug ?? | ||
| 95 | models.find((model) => !model.isCustom)?.slug ?? | 96 | models.find((model) => !model.isCustom)?.slug ?? |
| 96 | models[0]?.slug ?? | 97 | models[0]?.slug ?? |
| 97 | DEFAULT_MODEL_BY_PROVIDER[provider] ?? | 98 | DEFAULT_MODEL_BY_PROVIDER[provider] ?? |
packages/client-runtime/src/operations/projects.test.ts+2-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL, | ||
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ProjectId, | 5 | ProjectId, |
| 5 | CommandId, | 6 | CommandId, |
| ... | @@ -139,7 +140,7 @@ describe("add project shared logic", () => { | ... | @@ -139,7 +140,7 @@ describe("add project shared logic", () => { |
| 139 | createWorkspaceRootIfMissing: true, | 140 | createWorkspaceRootIfMissing: true, |
| 140 | defaultModelSelection: { | 141 | defaultModelSelection: { |
| 141 | instanceId: "codex", | 142 | instanceId: "codex", |
| 142 | model: "gpt-5.4", | 143 | model: DEFAULT_MODEL, |
| 143 | }, | 144 | }, |
| 144 | }); | 145 | }); |
| 145 | }); | 146 | }); |
packages/contracts/src/model.ts+12-2| ... | @@ -133,8 +133,18 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); | ... | @@ -133,8 +133,18 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); | 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 135 | 135 | ||
| 136 | export const DEFAULT_MODEL = "gpt-5.4"; | 136 | export const DEFAULT_MODEL = "gpt-5.6-sol"; |
| 137 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; | 137 | |
| 138 | /** | ||
| 139 | * Codex default-model preference, most preferred first. The provider snapshot | ||
| 140 | * marks the first of these present in the live `model/list` response as | ||
| 141 | * default; when none are available, Codex's own `isDefault` flag wins. | ||
| 142 | */ | ||
| 143 | export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [ | ||
| 144 | "gpt-5.6-sol", | ||
| 145 | "gpt-5.6-terra", | ||
| 146 | ]; | ||
| 147 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; | ||
| 138 | 148 | ||
| 139 | export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { | 149 | export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { |
| 140 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, | 150 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, |
packages/contracts/src/server.ts+1| ... | @@ -64,6 +64,7 @@ export const ServerProviderModel = Schema.Struct({ | ... | @@ -64,6 +64,7 @@ export const ServerProviderModel = Schema.Struct({ |
| 64 | shortName: Schema.optional(TrimmedNonEmptyString), | 64 | shortName: Schema.optional(TrimmedNonEmptyString), |
| 65 | subProvider: Schema.optional(TrimmedNonEmptyString), | 65 | subProvider: Schema.optional(TrimmedNonEmptyString), |
| 66 | isCustom: Schema.Boolean, | 66 | isCustom: Schema.Boolean, |
| 67 | isDefault: Schema.optional(Schema.Boolean), | ||
| 67 | capabilities: Schema.NullOr(ModelCapabilities), | 68 | capabilities: Schema.NullOr(ModelCapabilities), |
| 68 | }); | 69 | }); |
| 69 | export type ServerProviderModel = typeof ServerProviderModel.Type; | 70 | export type ServerProviderModel = typeof ServerProviderModel.Type; |
packages/contracts/src/settings.test.ts+4-4| ... | @@ -88,14 +88,14 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | ... | @@ -88,14 +88,14 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 88 | }); | 88 | }); |
| 89 | 89 | ||
| 90 | describe("ServerSettings worktree defaults", () => { | 90 | describe("ServerSettings worktree defaults", () => { |
| 91 | it("defaults start-from-origin off for legacy configs", () => { | 91 | it("defaults start-from-origin on for legacy configs", () => { |
| 92 | expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); | 92 | expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); |
| 93 | }); | 93 | }); |
| 94 | 94 | ||
| 95 | it("accepts start-from-origin updates", () => { | 95 | it("accepts start-from-origin updates", () => { |
| 96 | expect( | 96 | expect( |
| 97 | decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, | 97 | decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, |
| 98 | ).toBe(true); | 98 | ).toBe(false); |
| 99 | }); | 99 | }); |
| 100 | }); | 100 | }); |
| 101 | 101 |
packages/contracts/src/settings.ts+1-1| ... | @@ -382,7 +382,7 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -382,7 +382,7 @@ export const ServerSettings = Schema.Struct({ |
| 382 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), | 382 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 383 | ), | 383 | ), |
| 384 | newWorktreesStartFromOrigin: Schema.Boolean.pipe( | 384 | newWorktreesStartFromOrigin: Schema.Boolean.pipe( |
| 385 | Schema.withDecodingDefault(Effect.succeed(false)), | 385 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 386 | ), | 386 | ), |
| 387 | addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 387 | addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 388 | textGenerationModelSelection: ModelSelection.pipe( | 388 | textGenerationModelSelection: ModelSelection.pipe( |