diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 7d393b27..c323e24c 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -3010,6 +3010,7 @@ describe('Settings > Providers tab', () => { expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({ model1mSupport: { main: true, + fable: false, haiku: false, sonnet: true, opus: false, @@ -3030,6 +3031,82 @@ describe('Settings > Providers tab', () => { })) }) + it('persists the Fable slot to its own env var with its own 1M marker', async () => { + providerStoreState.createProvider = vi.fn().mockResolvedValue({ + id: 'provider-fable', + presetId: 'custom', + name: 'Custom', + apiKey: 'sk-test', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'claude-sonnet-4-6', + fable: 'claude-fable-5', + haiku: 'claude-sonnet-4-6', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-sonnet-4-6', + }, + model1mSupport: { + main: false, + fable: true, + haiku: false, + sonnet: false, + opus: false, + }, + }) + providerStoreState.presets = [ + { + id: 'custom', + name: 'Custom', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + // Fable must be settable from the form, not only by hand-editing + // settings.json or importing a cc-switch profile. + defaultModels: { + main: 'claude-sonnet-4-6', + fable: 'claude-fable-5', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: '', + }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: /Add Model|添加模型/i })) + const dialog = screen.getByRole('dialog') + await waitFor(() => { + const settingsTextarea = dialog.querySelector('textarea') + expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"') + }) + fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } }) + + // The slot is editable from the form rather than being carried invisibly. + // Queried by label because the input only takes the `combobox` role once a + // model list has been fetched. + const fableInput = within(dialog).getByLabelText(/Fable Model/i) + expect(fableInput).toHaveValue('claude-fable-5') + // Fable is the one slot that does not fall back to the main model, so it + // must not advertise "Same as main" the way the tier slots do. + expect(fableInput).toHaveAttribute('placeholder', 'Leave blank to let Claude Code choose') + expect(within(dialog).getByLabelText(/Haiku Model/i)) + .toHaveAttribute('placeholder', 'Same as main') + + fireEvent.click(within(dialog).getByRole('checkbox', { name: /1M support: fable/i })) + fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i })) + + await waitFor(() => { + expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]', + }), + })) + }) + }) + it('hides the API key by default and reveals it from the eye button', () => { providerStoreState.presets = [ { @@ -3157,7 +3234,7 @@ describe('Settings > Providers tab', () => { }) }) expect(await within(dialog).findByText(/Model list loaded \(2\)/i)).toBeInTheDocument() - expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(4) + expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(5) // The picker supplements the field; a model id that is not on the list must // still be typeable. Queried by role because the picker's own accessible diff --git a/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx b/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx index 95649802..cca5741d 100644 --- a/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx @@ -354,4 +354,51 @@ describe('AgentTeamsCanvas', () => { /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.agent-teams-task-running-fill\s*\{[^}]*opacity:\s*0;/, ) }) + + it('chips each member card with its model family and keeps the full id on hover', () => { + const current = snapshot('current') + const withModels: TeamWorkbenchSnapshot = { + ...current, + team: { + ...current.team, + members: current.team.members.map(member => ( + member.agentId === 'team-lead@canvas-team' + ? { ...member, model: 'claude-opus-4-8' } + : member.agentId === 'builder@canvas-team' + ? { ...member, model: 'claude-sonnet-5' } + : member + )), + }, + } + + render() + + // A member with its own model shows the short family, full id on hover. + const builder = screen.getByTestId('agent-teams-canvas-member-model-builder@canvas-team') + expect(builder.textContent).toBe('sonnet') + expect(builder.getAttribute('title')).toBe('claude-sonnet-5') + expect(builder.getAttribute('data-model-inherited')).toBe('false') + + // The lead shows its own model, and a member with none inherits it. + const lead = screen.getByTestId('agent-teams-canvas-member-model-team-lead@canvas-team') + expect(lead.textContent).toBe('opus') + const reviewer = screen.getByTestId('agent-teams-canvas-member-model-reviewer@canvas-team') + expect(reviewer.textContent).toBe('opus') + expect(reviewer.getAttribute('data-model-inherited')).toBe('true') + }) + + it('renders no model chip at all when neither the member nor the lead has one', () => { + render() + + const builder = screen.queryByTestId('agent-teams-canvas-member-model-builder@canvas-team') + expect(builder).toBeNull() + const lead = screen.queryByTestId('agent-teams-canvas-member-model-team-lead@canvas-team') + expect(lead).toBeNull() + }) }) diff --git a/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx b/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx index 384d0bcf..e593eb50 100644 --- a/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx @@ -15,6 +15,7 @@ import { inferTaskOwner, layoutWorkbenchTasks, parseWorkbenchMessageBody, + resolveMemberModel, resolveTeamMemberIdentity, taskOwnedByMember, type MemberWorkState, @@ -437,6 +438,7 @@ function MemberNode({ hasActiveMessage, waitingDependency, leadStatus, + leadModel, onSelect, t, }: { @@ -445,10 +447,12 @@ function MemberNode({ hasActiveMessage: boolean waitingDependency?: string leadStatus?: string + leadModel?: string onSelect: () => void t: TranslationFn }) { const { member, isLead, centerX, accent, workState: state } = position + const model = resolveMemberModel(member, isLead ? undefined : leadModel) const top = isLead ? LEAD_TOP : MEMBER_TOP const avatarSize = isLead ? 96 : 84 const identityWidth = isLead ? 28 : 24 @@ -519,6 +523,16 @@ function MemberNode({ {memberName(member)} + {model ? ( + + {model.label} + + ) : null} {isLead ? ( {t('agentTeams.leader')} @@ -714,6 +728,7 @@ export function AgentTeamsCanvas({ const leadMember = uniqueMembers.find(member => member.agentId === snapshot.team.leadAgentId) ?? uniqueMembers.find(member => aliases(member.name).includes('team-lead')) ?? uniqueMembers[0] + const leadModel = leadMember?.model const workers = uniqueMembers.filter(member => member !== leadMember) const naturalWidth = Math.max(CANVAS_MIN_WIDTH, 460 + Math.max(0, workers.length - 1) * MEMBER_PITCH) const layout = useMemo( @@ -1033,6 +1048,7 @@ export function AgentTeamsCanvas({ hasActiveMessage={activeParticipants.has(position.member.agentId)} waitingDependency={waitingDependency} leadStatus={position.isLead ? leadStatusLabel(snapshot, t) : undefined} + leadModel={leadModel} onSelect={() => onSelectMember(position.member, position.isLead)} t={t} /> diff --git a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx index 41e5fe38..0dbdba1b 100644 --- a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx @@ -170,4 +170,53 @@ describe('AgentTeamsMemberInspector', () => { expect(onClose).toHaveBeenCalledTimes(1) expect(onOpenExecution).toHaveBeenCalledTimes(1) }) + + it('reports the model a teammate runs on, and flags one that inherits the lead', () => { + const lead: TeamMember = { + agentId: 'lead@team-a', + name: 'lead', + role: 'Lead', + status: 'running', + model: 'claude-opus-4-8', + } + const base = snapshot('2026-08-08T07:00:00.000Z', 'pending') + const withLead: TeamWorkbenchSnapshot = { + ...base, + team: { ...base.team, members: [lead, builder, reviewer] }, + } + const renderMember = (member: TeamMember) => render( + , + ) + + // A teammate with its own model shows the full id, not the short family. + const own = renderMember({ ...builder, model: 'claude-haiku-4-5' }) + const ownCell = screen.getByTestId('agent-teams-member-model') + expect(ownCell.textContent).toBe('claude-haiku-4-5') + expect(ownCell.getAttribute('data-model-inherited')).toBe('false') + own.unmount() + + // One with no model of its own names where the model comes from. + renderMember({ ...builder }) + const inheritedCell = screen.getByTestId('agent-teams-member-model') + expect(inheritedCell.textContent).toBe('Inherit from lead · claude-opus-4-8') + expect(inheritedCell.getAttribute('data-model-inherited')).toBe('true') + }) + + it('never leaves the model cell blank when nothing is known', () => { + renderInspector() + + const cell = screen.getByTestId('agent-teams-member-model') + expect(cell.textContent).toBe('Unknown') + expect(cell.textContent).not.toContain('undefined') + }) }) diff --git a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx index 6a8c5ba4..8e7b4694 100644 --- a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx @@ -10,6 +10,7 @@ import { getWorkbenchTaskState, inferTaskOwner, parseWorkbenchMessageBody, + resolveMemberModel, resolveTeamMemberIdentity, taskOwnedByMember, type MemberWorkState, @@ -297,6 +298,8 @@ export function AgentTeamsMemberInspector({ const workState = snapshot.deletedAt ? 'exited' : getMemberWorkState(member, { isLead, leadIsStreaming }) + const leadMember = snapshot.team.members.find(candidate => candidate.agentId === snapshot.team.leadAgentId) + const model = resolveMemberModel(member, isLead ? undefined : leadMember?.model) const taskHistory = useMemo( () => deriveTaskHistory(snapshots, selectedIndex, snapshot, member), [member, selectedIndex, snapshot, snapshots], @@ -396,6 +399,26 @@ export function AgentTeamsMemberInspector({
{messages.length}
+ {/* Model spans the full width: a model id plus an "inherited from" prefix + does not fit a third of the drawer, and truncating it would cut the + model name itself — the one part that matters. */} +
+
+ {t('agentTeams.model.label')} +
+
+ {model === undefined + ? t('agentTeams.model.unknown') + : model.inherited + ? t('agentTeams.model.inheritFromLead', { model: model.full }) + : model.full} +
+
diff --git a/desktop/src/components/agentTeams/agentTeamsModel.test.ts b/desktop/src/components/agentTeams/agentTeamsModel.test.ts index 03571b19..f2652bf7 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.test.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.test.ts @@ -9,8 +9,10 @@ import { inferTaskOwner, layoutWorkbenchTasks, parseWorkbenchMessageBody, + resolveMemberModel, resolveTeamMemberIdentity, runningTaskForMember, + shortModelLabel, snapshotWithHistoricalMembers, taskOwnedByMember, WORKBENCH_TASK_WIDTH, @@ -442,3 +444,103 @@ describe('Agent Teams workbench model', () => { expect(formatWorkbenchMessageTime('not-a-date')).toBe('') }) }) + +describe('Agent Teams member model display', () => { + const worker: TeamMember = { + agentId: 'builder@team-a', + name: 'builder', + role: 'Frontend', + status: 'running', + } + + function memberSnapshot(generatedAt: string, members: TeamMember[]): TeamWorkbenchSnapshot { + return { + version: generatedAt, + generatedAt, + team: { + name: 'team-a', + leadAgentId: 'lead@team-a', + leadSessionId: 'session-a', + members, + }, + tasks: [], + messages: [], + } + } + + it('shortens a full model id to its family and passes unknown ids through', () => { + expect(shortModelLabel('claude-opus-4-8')).toBe('opus') + expect(shortModelLabel('eu.anthropic.claude-sonnet-5')).toBe('sonnet') + expect(shortModelLabel('claude-haiku-4-5')).toBe('haiku') + expect(shortModelLabel('claude-fable-5')).toBe('fable') + expect(shortModelLabel('sonnet')).toBe('sonnet') + // An unknown family is preserved rather than forced into a known one. + expect(shortModelLabel('gpt-5')).toBe('gpt-5') + }) + + it('keeps the full id for the tooltip while the chip shows the short family', () => { + expect(resolveMemberModel({ ...worker, model: 'claude-opus-4-8' })) + .toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false }) + }) + + it('trims the stored id so no padding leaks into the label or the tooltip', () => { + expect(resolveMemberModel({ ...worker, model: ' claude-opus-4-8 ' })) + .toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false }) + }) + + it('drops the context-window tag from the chip while the tooltip keeps it', () => { + // `[1m]` is a routing detail, and leaving it on made the chip wide enough to + // squeeze the member name out of the card. + expect(shortModelLabel('qwen3.7-plus[1m]')).toBe('qwen3.7-plus') + expect(shortModelLabel('claude-opus-4-8[1m]')).toBe('opus') + expect(resolveMemberModel({ ...worker, model: 'qwen3.7-plus[1m]' })) + .toEqual({ label: 'qwen3.7-plus', full: 'qwen3.7-plus[1m]', inherited: false }) + // A value that is nothing but a tag must not collapse to an empty label. + expect(shortModelLabel('[1m]')).toBe('[1m]') + }) + + it('marks a member with no model of its own as inheriting the lead model', () => { + expect(resolveMemberModel({ ...worker }, 'claude-sonnet-5')) + .toEqual({ label: 'sonnet', full: 'claude-sonnet-5', inherited: true }) + }) + + it('treats an explicit inherit alias as inheritance, not as a model name', () => { + expect(resolveMemberModel({ ...worker, model: 'inherit' }, 'claude-opus-4-8')) + .toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true }) + expect(resolveMemberModel({ ...worker, model: 'INHERIT' }, 'claude-opus-4-8')) + .toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true }) + }) + + it('returns undefined when nothing is known, so callers render no empty label', () => { + expect(resolveMemberModel({ ...worker })).toBeUndefined() + expect(resolveMemberModel({ ...worker, model: '' })).toBeUndefined() + expect(resolveMemberModel({ ...worker, model: ' ' })).toBeUndefined() + expect(resolveMemberModel({ ...worker, model: 'inherit' })).toBeUndefined() + // A blank lead model must not be promoted into an inherited label either. + expect(resolveMemberModel({ ...worker }, ' ')).toBeUndefined() + }) + + it('keeps a model learned from an earlier frame when a later frame omits it', () => { + const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [ + { ...worker, model: 'claude-opus-4-8' }, + ]) + const later = memberSnapshot('2026-08-08T07:05:00.000Z', [{ ...worker }]) + + const merged = snapshotWithHistoricalMembers([earlier, later], 1) + + expect(merged?.team.members[0]?.model).toBe('claude-opus-4-8') + }) + + it('lets a later frame update the model when it actually carries one', () => { + const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [ + { ...worker, model: 'claude-opus-4-8' }, + ]) + const later = memberSnapshot('2026-08-08T07:05:00.000Z', [ + { ...worker, model: 'claude-haiku-4-5' }, + ]) + + const merged = snapshotWithHistoricalMembers([earlier, later], 1) + + expect(merged?.team.members[0]?.model).toBe('claude-haiku-4-5') + }) +}) diff --git a/desktop/src/components/agentTeams/agentTeamsModel.ts b/desktop/src/components/agentTeams/agentTeamsModel.ts index 231dc746..2eddfa17 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.ts @@ -111,6 +111,69 @@ export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAv return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key } +/** + * Short family label for a model id or alias, used by compact UI chips. + * 'claude-opus-4-8' -> 'opus', 'eu.anthropic.claude-sonnet-5' -> 'sonnet', + * 'sonnet' -> 'sonnet'. Unrecognized values are returned trimmed and unchanged + * rather than being forced into a family. + * + * A trailing context-window tag (`[1m]`) is a routing detail, not part of the + * name a reader recognises, so it is dropped before matching. + */ +export function shortModelLabel(model: string): string { + const untagged = model.trim().replace(/\[[^\]]*\]$/, '').trim() + // A value that was nothing but a tag keeps its original form rather than + // collapsing to an empty label. + const trimmed = untagged || model.trim() + if (!trimmed) return trimmed + const lowered = trimmed.toLowerCase() + if (lowered.includes('opus')) return 'opus' + if (lowered.includes('sonnet')) return 'sonnet' + if (lowered.includes('haiku')) return 'haiku' + if (lowered.includes('fable')) return 'fable' + return trimmed +} + +export type MemberModelDisplay = { + /** Short chip label, e.g. 'opus'. */ + label: string + /** Full model id or alias, used for the hover tooltip. */ + full: string + /** True when the member runs on the lead's model rather than one of its own. */ + inherited: boolean +} + +/** + * Model display for one member. Returns undefined when nothing is known, so + * callers render no chip instead of an empty or 'undefined' label. + * + * `leadModel` is the lead member's model. Callers pass undefined for the lead + * itself; a lead without an explicit model therefore renders no chip. + */ +export function resolveMemberModel( + member: TeamMember, + leadModel?: string, +): MemberModelDisplay | undefined { + const memberModelRaw = member.model + const memberModel = typeof memberModelRaw === 'string' ? memberModelRaw.trim() : '' + if (memberModel && memberModel.toLowerCase() !== 'inherit') { + return { + label: shortModelLabel(memberModel), + full: memberModel, + inherited: false, + } + } + const resolvedLead = typeof leadModel === 'string' ? leadModel.trim() : '' + if (resolvedLead) { + return { + label: shortModelLabel(resolvedLead), + full: resolvedLead, + inherited: true, + } + } + return undefined +} + function identityAliases(value: string): string[] { const normalized = value.trim().toLowerCase() const short = normalized.split('@')[0] ?? normalized @@ -153,7 +216,13 @@ export function snapshotWithHistoricalMembers( } } const [currentMember] = remaining.splice(currentIndex, 1) - return { ...historicalMember, ...currentMember! } + const merged = { ...historicalMember, ...currentMember! } + // A later frame can land with `model: undefined`, which would clobber a + // model already learned from an earlier snapshot. Restore the historical + // model when the incoming member has no model of its own. + const currentModel = currentMember?.model + const hasCurrentModel = typeof currentModel === 'string' && currentModel.trim() !== '' + return hasCurrentModel ? merged : { ...merged, model: historicalMember.model } }) members.push(...remaining) } diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index bb9760f4..227bcbdc 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -79,7 +79,7 @@ describe('ModelSelector', () => { id: 'provider-1m', presetId: 'custom', name: 'Provider 1M', apiFormat: 'anthropic', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:9999', models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' }, - model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled }, + model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled }, }], }) const runtimeChange = vi.fn() diff --git a/desktop/src/components/settings/CcSwitchImportModal.test.tsx b/desktop/src/components/settings/CcSwitchImportModal.test.tsx index d8d3156e..1640a167 100644 --- a/desktop/src/components/settings/CcSwitchImportModal.test.tsx +++ b/desktop/src/components/settings/CcSwitchImportModal.test.tsx @@ -30,7 +30,7 @@ function makeCandidate(overrides: Partial = {}): CcSwitchCand sonnet: 'relay-sonnet', opus: 'relay-opus', }, - model1mSupport: { main: false, haiku: false, sonnet: false, opus: false }, + model1mSupport: { main: false, fable: false, haiku: false, sonnet: false, opus: false }, apiFormat: 'anthropic', authStrategy: 'auth_token', presetId: 'custom', diff --git a/desktop/src/dev/ComponentGallery.tsx b/desktop/src/dev/ComponentGallery.tsx index e082dc07..37ba55d6 100644 --- a/desktop/src/dev/ComponentGallery.tsx +++ b/desktop/src/dev/ComponentGallery.tsx @@ -99,6 +99,7 @@ function galleryTeam( agentId: string, role: string, status: TeamDetail['members'][number]['status'], + model?: string, ): TeamDetail['members'][number] => ({ agentId, name: agentId, @@ -108,6 +109,7 @@ function galleryTeam( currentTask: activeTaskByMember[agentId], color: AGENT_TEAMS_GALLERY_COLORS[agentId], sessionId: `gallery-${agentId}`, + ...(model ? { model } : {}), }) return { @@ -117,11 +119,11 @@ function galleryTeam( leadSessionId: AGENT_TEAMS_GALLERY_SESSION_ID, createdAt, members: [ - member('team-lead', '队长', 'running'), - member('backend-dev', '后端实现', 'running'), - member('frontend-dev', '前端实现', 'running'), - member('ui-reviewer', 'UI 走查', 'running'), - member('code-reviewer', '代码审查', 'running'), + member('team-lead', '队长', 'running', 'claude-opus-4-8'), + member('backend-dev', '后端实现', 'running', 'claude-sonnet-5'), + member('frontend-dev', '前端实现', 'running', 'claude-sonnet-5'), + member('ui-reviewer', 'UI 走查', 'running', 'claude-haiku-4-5'), + member('code-reviewer', '代码审查', 'running', 'qwen3.7-plus[1m]'), member('test-runner', '测试验证', 'running'), ], } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 8ce165a7..826f50f3 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1300,6 +1300,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.providers.getApiKey': 'Get API Key', 'settings.providers.modelMapping': 'Model Mapping', 'settings.providers.mainModel': 'Main Model', + 'settings.providers.fableModel': 'Fable Model', 'settings.providers.haikuModel': 'Haiku Model', 'settings.providers.sonnetModel': 'Sonnet Model', 'settings.providers.opusModel': 'Opus Model', @@ -1308,6 +1309,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.providers.model1mSupportTooltip': 'Declares that this model endpoint supports a 1M-token context: the model name gets a [1m] suffix and the context window is set to 1,000,000. If the endpoint is actually smaller, auto-compaction never fires and requests hit the provider’s hard context limit. Clearing the box restores the preset window.', 'settings.providers.modelIdPlaceholder': 'e.g. deepseek-v4-flash', 'settings.providers.sameAsMain': 'Same as main', + 'settings.providers.fableModelPlaceholder': 'Leave blank to let Claude Code choose', 'settings.providers.contextSettingsTitle': 'Context and auto-compact', 'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.', 'settings.providers.contextSettingsEdit': 'Advanced', @@ -3086,6 +3088,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'agentTeams.inspector.taskHistoryHint': 'One teammate executes multiple tasks in sequence', 'agentTeams.inspector.sent': 'Sent', 'agentTeams.inspector.received': 'Received', + 'agentTeams.model.label': 'Model', + 'agentTeams.model.inheritFromLead': 'Inherit from lead · {model}', + 'agentTeams.model.unknown': 'Unknown', 'agentTeams.hideReport': 'Hide Agent Teams run report', 'agentTeams.closeReport': 'Close Agent Teams run report', 'agentTeams.loading': 'Waiting for Agent Teams activity…', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index b5802efa..37a1f581 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1302,6 +1302,7 @@ export const jp: Record = { 'settings.providers.getApiKey': 'API キーを取得', 'settings.providers.modelMapping': 'モデルマッピング', 'settings.providers.mainModel': 'メインモデル', + 'settings.providers.fableModel': 'Fable モデル', 'settings.providers.haikuModel': 'Haiku モデル', 'settings.providers.sonnetModel': 'Sonnet モデル', 'settings.providers.opusModel': 'Opus モデル', @@ -1310,6 +1311,7 @@ export const jp: Record = { 'settings.providers.model1mSupportTooltip': 'このモデルのエンドポイントが 100 万トークンのコンテキストに対応していることを宣言します。モデル名に [1m] が付き、コンテキストウィンドウは 1,000,000 に設定されます。実際のウィンドウが 1M より小さい場合、自動圧縮は実行されず、リクエストはプロバイダーのコンテキスト上限に達します。チェックを外すとプリセットのウィンドウに戻ります。', 'settings.providers.modelIdPlaceholder': '例: deepseek-v4-flash', 'settings.providers.sameAsMain': 'メインと同じ', + 'settings.providers.fableModelPlaceholder': '空欄の場合は Claude Code が決定', 'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト', 'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。', 'settings.providers.contextSettingsEdit': '詳細設定', @@ -3088,6 +3090,9 @@ export const jp: Record = { 'agentTeams.inspector.taskHistoryHint': '1 人のメンバーが複数タスクを順番に実行', 'agentTeams.inspector.sent': '送信', 'agentTeams.inspector.received': '受信', + 'agentTeams.model.label': 'モデル', + 'agentTeams.model.inheritFromLead': 'リードから継承 · {model}', + 'agentTeams.model.unknown': '不明', 'agentTeams.hideReport': 'Agent Teams 実行レポートを隠す', 'agentTeams.closeReport': 'Agent Teams 実行レポートを閉じる', 'agentTeams.loading': 'Agent Teams のアクティビティを待機中…', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index d812e765..15f5a613 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1304,6 +1304,7 @@ export const kr: Record = { 'settings.providers.getApiKey': 'API 키 가져오기', 'settings.providers.modelMapping': '모델 매핑', 'settings.providers.mainModel': '메인 모델', + 'settings.providers.fableModel': 'Fable 모델', 'settings.providers.haikuModel': 'Haiku 모델', 'settings.providers.sonnetModel': 'Sonnet 모델', 'settings.providers.opusModel': 'Opus 모델', @@ -1312,6 +1313,7 @@ export const kr: Record = { 'settings.providers.model1mSupportTooltip': '이 모델 엔드포인트가 100만 토큰 컨텍스트를 지원한다고 선언합니다. 모델 이름에 [1m]이 추가되고 컨텍스트 창이 1,000,000으로 설정됩니다. 엔드포인트의 실제 창이 1M보다 작으면 자동 압축이 실행되지 않고 요청이 공급자의 컨텍스트 한도에 도달합니다. 선택을 해제하면 사전 설정 창으로 되돌아갑니다.', 'settings.providers.modelIdPlaceholder': '예: deepseek-v4-flash', 'settings.providers.sameAsMain': '메인과 동일', + 'settings.providers.fableModelPlaceholder': '비워 두면 Claude Code가 결정', 'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축', 'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.', 'settings.providers.contextSettingsEdit': '고급', @@ -3090,6 +3092,9 @@ export const kr: Record = { 'agentTeams.inspector.taskHistoryHint': '한 멤버가 여러 작업을 순차 실행', 'agentTeams.inspector.sent': '보냄', 'agentTeams.inspector.received': '받음', + 'agentTeams.model.label': '모델', + 'agentTeams.model.inheritFromLead': '리드로부터 상속 · {model}', + 'agentTeams.model.unknown': '알 수 없음', 'agentTeams.hideReport': 'Agent Teams 실행 보고서 숨기기', 'agentTeams.closeReport': 'Agent Teams 실행 보고서 닫기', 'agentTeams.loading': 'Agent Teams 활동을 기다리는 중…', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 85812dab..62198fd3 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1301,6 +1301,7 @@ export const zh: Record = { 'settings.providers.getApiKey': '獲取 API Key', 'settings.providers.modelMapping': '模型對映', 'settings.providers.mainModel': '主模型', + 'settings.providers.fableModel': 'Fable 模型', 'settings.providers.haikuModel': 'Haiku 模型', 'settings.providers.sonnetModel': 'Sonnet 模型', 'settings.providers.opusModel': 'Opus 模型', @@ -1309,6 +1310,7 @@ export const zh: Record = { 'settings.providers.model1mSupportTooltip': '宣告該模型端點支援 100 萬 token 上下文:模型名稱會附加 [1m],上下文視窗自動填為 1,000,000。若端點實際視窗小於 1M,自動壓縮不會觸發,請求會撞上服務商的上下文硬限制。取消勾選會還原為預設視窗。', 'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash', 'settings.providers.sameAsMain': '與主模型相同', + 'settings.providers.fableModelPlaceholder': '留空則由 Claude Code 決定', 'settings.providers.contextSettingsTitle': '上下文與自動壓縮', 'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。', 'settings.providers.contextSettingsEdit': '高階', @@ -3087,6 +3089,9 @@ export const zh: Record = { 'agentTeams.inspector.taskHistoryHint': '一位成員依序執行多個任務', 'agentTeams.inspector.sent': '送出', 'agentTeams.inspector.received': '收到', + 'agentTeams.model.label': '模型', + 'agentTeams.model.inheritFromLead': '繼承主代理 · {model}', + 'agentTeams.model.unknown': '未知', 'agentTeams.hideReport': '隱藏 Agent Teams 執行報告', 'agentTeams.closeReport': '關閉 Agent Teams 執行報告', 'agentTeams.loading': '正在等待 Agent Teams 活動…', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index b7a4cc0d..6be6445b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1300,6 +1300,7 @@ export const zh: Record = { 'settings.providers.getApiKey': '获取 API Key', 'settings.providers.modelMapping': '模型映射', 'settings.providers.mainModel': '主模型', + 'settings.providers.fableModel': 'Fable 模型', 'settings.providers.haikuModel': 'Haiku 模型', 'settings.providers.sonnetModel': 'Sonnet 模型', 'settings.providers.opusModel': 'Opus 模型', @@ -1308,6 +1309,7 @@ export const zh: Record = { 'settings.providers.model1mSupportTooltip': '声明该模型端点支持 100 万 token 上下文:模型名会追加 [1m],上下文窗口自动填为 1,000,000。若端点实际窗口小于 1M,自动压缩不会触发,请求会撞上服务商的上下文硬限制。取消勾选会恢复预设窗口。', 'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash', 'settings.providers.sameAsMain': '与主模型相同', + 'settings.providers.fableModelPlaceholder': '留空则由 Claude Code 决定', 'settings.providers.contextSettingsTitle': '上下文与自动压缩', 'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。', 'settings.providers.contextSettingsEdit': '高级', @@ -3086,6 +3088,9 @@ export const zh: Record = { 'agentTeams.inspector.taskHistoryHint': '一个成员串行执行多个任务', 'agentTeams.inspector.sent': '发出', 'agentTeams.inspector.received': '收到', + 'agentTeams.model.label': '模型', + 'agentTeams.model.inheritFromLead': '继承主代理 · {model}', + 'agentTeams.model.unknown': '未知', 'agentTeams.hideReport': '隐藏 Agent Teams 运行报告', 'agentTeams.closeReport': '关闭 Agent Teams 运行报告', 'agentTeams.loading': '正在等待 Agent Teams 活动…', diff --git a/desktop/src/lib/__tests__/providerModelContext.test.ts b/desktop/src/lib/__tests__/providerModelContext.test.ts index 7979d1c9..21e3a8b9 100644 --- a/desktop/src/lib/__tests__/providerModelContext.test.ts +++ b/desktop/src/lib/__tests__/providerModelContext.test.ts @@ -9,7 +9,7 @@ import { } from '../providerModelContext' function inputs(overrides: Partial = {}): ModelContextInputs { - return { main: '', haiku: '', sonnet: '', opus: '', ...overrides } + return { main: '', fable: '', haiku: '', sonnet: '', opus: '', ...overrides } } describe('1M support and the context window field', () => { @@ -87,6 +87,7 @@ describe('1M support and the context window field', () => { const next = apply1mSupportToContextInputs(before, { main: true, + fable: false, haiku: false, sonnet: false, opus: false, diff --git a/desktop/src/lib/providerModelContext.ts b/desktop/src/lib/providerModelContext.ts index 80440795..1da028fc 100644 --- a/desktop/src/lib/providerModelContext.ts +++ b/desktop/src/lib/providerModelContext.ts @@ -2,7 +2,7 @@ import type { Model1mSupport } from '../types/provider' -export const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const +export const MODEL_SLOTS = ['main', 'fable', 'haiku', 'sonnet', 'opus'] as const export type ModelSlot = typeof MODEL_SLOTS[number] export type ModelContextInputs = Record diff --git a/desktop/src/lib/runtimeSelection.test.ts b/desktop/src/lib/runtimeSelection.test.ts index d1f4cdd9..18ab4575 100644 --- a/desktop/src/lib/runtimeSelection.test.ts +++ b/desktop/src/lib/runtimeSelection.test.ts @@ -144,7 +144,7 @@ describe('provider 1M runtime selection', () => { id: 'provider', name: 'Provider', presetId: 'custom', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:9999', apiFormat: 'anthropic', models: { main: ' main-model ', haiku: 'fast-model', sonnet: 'balanced-model', opus: 'large-model' }, - model1mSupport: { main: true, haiku: false, sonnet: true, opus: false }, + model1mSupport: { main: true, fable: false, haiku: false, sonnet: true, opus: false }, } it('materializes the active provider main slot by id and by legacy name', () => { diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 8098cd7a..29e6cd8b 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -838,7 +838,7 @@ describe('EmptySession', () => { sonnet: 'MiniMax-M3', opus: 'MiniMax-M3', }, - model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled }, + model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled }, toolSearchEnabled: true, }], activeId: 'provider-minimax', diff --git a/desktop/src/pages/settings/ProviderSettings.tsx b/desktop/src/pages/settings/ProviderSettings.tsx index ef28770e..24fbc2bf 100644 --- a/desktop/src/pages/settings/ProviderSettings.tsx +++ b/desktop/src/pages/settings/ProviderSettings.tsx @@ -7,7 +7,7 @@ import aruhubLogo from '../../../../docs/images/sponsors/aruhub-logo.png' import { useSettingsStore } from '../../stores/settingsStore' import { useProviderStore } from '../../stores/providerStore' import { useUIStore } from '../../stores/uiStore' -import { useTranslation } from '../../i18n' +import { useTranslation, type TranslationKey } from '../../i18n' import { Modal } from '@/components/ui/Modal' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { Input } from '@/components/ui/Input' @@ -59,6 +59,15 @@ type ProviderListItem = | { id: typeof GROK_OFFICIAL_PROVIDER_ID; kind: 'grok-official' } | { id: string; kind: 'saved'; provider: SavedProvider } +/** Row labels for the model-mapping form, in `MODEL_SLOTS` order. */ +const MODEL_SLOT_LABEL_KEYS: Record = { + main: 'settings.providers.mainModel', + fable: 'settings.providers.fableModel', + haiku: 'settings.providers.haikuModel', + sonnet: 'settings.providers.sonnetModel', + opus: 'settings.providers.opusModel', +} + function defaultProviderOrder(providers: SavedProvider[]): string[] { return [ ...providers.map((provider) => provider.id), @@ -562,6 +571,7 @@ const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS' const DISABLE_EXPERIMENTAL_BETAS_ENV_KEY = 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS' const DEFAULT_MODEL_1M_SUPPORT: Model1mSupport = { main: false, + fable: false, haiku: false, sonnet: false, opus: false, @@ -699,6 +709,7 @@ function getInitialModel1mSupport( ): Model1mSupport { return { main: provider?.model1mSupport?.main === true || hasModel1mMarker(models.main), + fable: provider?.model1mSupport?.fable === true || (models.fable ? hasModel1mMarker(models.fable) : false), haiku: provider?.model1mSupport?.haiku === true || hasModel1mMarker(models.haiku), sonnet: provider?.model1mSupport?.sonnet === true || hasModel1mMarker(models.sonnet), opus: provider?.model1mSupport?.opus === true || hasModel1mMarker(models.opus), @@ -716,7 +727,7 @@ function applyModel1mSupportMapping( ): ModelMapping { return { main: applyModel1mSupport(models.main, model1mSupport.main), - ...(models.fable ? { fable: stripModel1mMarker(models.fable) } : {}), + ...(models.fable ? { fable: applyModel1mSupport(models.fable, model1mSupport.fable) } : {}), haiku: applyModel1mSupport(models.haiku, model1mSupport.haiku), sonnet: applyModel1mSupport(models.sonnet, model1mSupport.sonnet), opus: applyModel1mSupport(models.opus, model1mSupport.opus), @@ -1856,23 +1867,26 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode )}
{MODEL_SLOTS.map((slot) => { - const labelKey = slot === 'main' - ? 'settings.providers.mainModel' - : slot === 'haiku' - ? 'settings.providers.haikuModel' - : slot === 'sonnet' - ? 'settings.providers.sonnetModel' - : 'settings.providers.opusModel' - const label = t(labelKey) + const label = t(MODEL_SLOT_LABEL_KEYS[slot]) const pickLabel = t('settings.providers.fetchModelsPick', { label }) return (
handleModelChange(slot, value)} - placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')} + placeholder={ + // Fable is the one slot that does not fall back to the + // main model: an empty value leaves the choice to the + // runtime, which resolves it to the provider's Opus-tier + // model (third-party) or a real Fable model (official). + slot === 'main' + ? t('settings.providers.modelIdPlaceholder') + : slot === 'fable' + ? t('settings.providers.fableModelPlaceholder') + : t('settings.providers.sameAsMain') + } groups={modelPickerGroups} pickerLabel={pickLabel} noMatchesLabel={t('model.noMatches')} @@ -2167,6 +2181,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode const mergedModels = { ...prev, ...newModels } const nextModel1mSupport = { main: hasModel1mMarker(mergedModels.main), + fable: mergedModels.fable ? hasModel1mMarker(mergedModels.fable) : false, haiku: hasModel1mMarker(mergedModels.haiku), sonnet: hasModel1mMarker(mergedModels.sonnet), opus: hasModel1mMarker(mergedModels.opus), diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index dd467a78..ff8f6ea5 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -5265,7 +5265,7 @@ describe('chatStore history mapping', () => { id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic', models: { main: model, haiku: '', sonnet: '', opus: '' }, - model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: false }, + model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: false }, }] const staleSelection = { providerId: 'provider-1', modelId: `${model}${enabled ? '' : '[1m]'}`, effortLevel: 'high' as const } const expectedSelection = { ...staleSelection, modelId: `${model}${enabled ? '[1m]' : ''}` } @@ -5290,7 +5290,7 @@ describe('chatStore history mapping', () => { id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic', models: { main: 'deepseek-v4.1', haiku: '', sonnet: '', opus: '' }, - model1mSupport: { main: true, haiku: false, sonnet: false, opus: false }, + model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false }, }] useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue') expect(sendMock.mock.calls.slice(0, 2)).toEqual([ diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts index 151feb15..1b1c2837 100644 --- a/desktop/src/stores/providerStore.test.ts +++ b/desktop/src/stores/providerStore.test.ts @@ -152,7 +152,7 @@ describe('providerStore runtime refresh', () => { }) it.each([true, false])('refreshes saved model capabilities for idle, disconnected and draft selections (1m=%s)', async (enabled) => { - const provider = makeProvider({ model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: enabled } }) + const provider = makeProvider({ model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: enabled } }) providersApiMock.update.mockResolvedValue({ provider }) providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id }) chatStoreState.sessions = { @@ -197,7 +197,7 @@ describe('providerStore runtime refresh', () => { }) it('reconciles restored selections when provider capabilities arrive after connection', async () => { - const provider = makeProvider({ model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } }) + const provider = makeProvider({ model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false } }) providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id }) chatStoreState.sessions = { restored: { connectionState: 'connected', chatState: 'idle' } } runtimeStoreState.selections = { restored: { providerId: provider.id, modelId: 'model-main' } } diff --git a/desktop/src/stores/teamStore.test.ts b/desktop/src/stores/teamStore.test.ts index a33b7fe5..0718c6eb 100644 --- a/desktop/src/stores/teamStore.test.ts +++ b/desktop/src/stores/teamStore.test.ts @@ -1387,6 +1387,49 @@ describe('teamStore workbench timeline', () => { useTeamStore.getState().clearTeam() }) + it('keeps each member model through the roster read instead of dropping it', async () => { + const snapshot = workbench('v1', 'in_progress') + getWorkbenchForSessionMock.mockResolvedValue({ + sessionId: 'lead-session', + teamName: 'team-workbench', + snapshots: [{ + ...snapshot, + team: { + ...snapshot.team, + members: [ + { ...snapshot.team.members[0]!, model: 'claude-opus-4-8' }, + // A mapped provider id is not a known family — it must still survive + // verbatim rather than being normalised away. + { ...snapshot.team.members[1]!, model: 'qwen3.7-plus[1m]' }, + ], + }, + }], + source: 'live', + }) + + await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true }) + + const members = useTeamStore.getState() + .workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? [] + expect(members.map(member => member.model)).toEqual(['claude-opus-4-8', 'qwen3.7-plus[1m]']) + }) + + it('reports no model rather than inventing one when the roster omits it', async () => { + const snapshot = workbench('v1', 'in_progress') + getWorkbenchForSessionMock.mockResolvedValue({ + sessionId: 'lead-session', + teamName: 'team-workbench', + snapshots: [snapshot], + source: 'live', + }) + + await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true }) + + const members = useTeamStore.getState() + .workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? [] + expect(members.map(member => member.model)).toEqual([undefined, undefined]) + }) + it('keeps unknown watcher identities out of each roster and refreshes authoritative data', () => { getTeamMock.mockReturnValue(new Promise(() => {})) getWorkbenchMock.mockReturnValue(new Promise(() => {})) diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index b3f15775..7da51a79 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -144,6 +144,7 @@ function toTeamMember(raw: Record): TeamMember { currentTask: raw.currentTask as string | undefined, color: raw.color as AgentColor | undefined, sessionId: raw.sessionId as string | undefined, + model: typeof raw.model === 'string' ? raw.model : undefined, } } diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index 0e4f39f7..dd50a9b1 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -21,6 +21,7 @@ export type ModelMapping = { export type Model1mSupport = { main: boolean + fable: boolean haiku: boolean sonnet: boolean opus: boolean diff --git a/desktop/src/types/team.ts b/desktop/src/types/team.ts index b61c5a1a..c4650178 100644 --- a/desktop/src/types/team.ts +++ b/desktop/src/types/team.ts @@ -25,6 +25,7 @@ export type TeamMember = { currentTask?: string color?: AgentColor sessionId?: string + model?: string } export type TeamDetail = { diff --git a/src/server/index.ts b/src/server/index.ts index 8ab0e65c..77b0373e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,7 +10,7 @@ import { handleSessionCollaborationApi } from './api/sessionCollaboration.js' import { handleApiRequest } from './router.js' import { handleWebSocket, type WebSocketData } from './ws/handler.js' -import { resolveCors, type CorsResolution } from './middleware/cors.js' +import { isLoopbackBrowserOrigin, resolveCors, type CorsResolution } from './middleware/cors.js' import { requireAuth, requireH5Token } from './middleware/auth.js' import { teamWatcher } from './services/teamWatcher.js' import { cronScheduler } from './services/cronScheduler.js' @@ -377,6 +377,23 @@ export function startServer(port = PORT, host = HOST) { }) const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext) + // A *loopback* renderer's CORS preflight is answered before the + // capability gates. A preflight never carries credentials, so a + // browser-hosted renderer (the Vite dev server on + // http://localhost:) could never reach the API once H5 access was + // disabled: the preflight was rejected with a CORS-less 403, the + // browser refused to send the real request, and the app could only + // report an opaque `TypeError: Failed to fetch`. This grants nothing — + // the actual request is still gated below and still has to present the + // process token. Remote origins deliberately skip this block and keep + // hitting the strict rejection path beneath it. + if (req.method === 'OPTIONS' && origin && isLoopbackBrowserOrigin(origin)) { + if (cors.rejected) { + return corsRejectedResponse(cors) + } + return new Response(null, { status: 204, headers: cors.headers }) + } + if (h5AccessControlBlocked) { return isLocalCredentialOnlyPath(url.pathname) ? localCredentialRejectedResponse() diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index 9d946438..a22236b0 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -45,7 +45,7 @@ function isLocalOrigin(origin?: string | null): boolean { return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin) } -function isLoopbackBrowserOrigin(origin: string): boolean { +export function isLoopbackBrowserOrigin(origin: string): boolean { let parsed: URL try { parsed = new URL(origin) diff --git a/src/server/services/ccSwitchImport.test.ts b/src/server/services/ccSwitchImport.test.ts index 206ea09e..ce531905 100644 --- a/src/server/services/ccSwitchImport.test.ts +++ b/src/server/services/ccSwitchImport.test.ts @@ -1062,6 +1062,7 @@ describe('cc-switch candidate mapping', () => { }) expect(candidate.model1mSupport).toEqual({ main: false, + fable: false, haiku: false, sonnet: false, opus: false, @@ -1093,6 +1094,7 @@ describe('cc-switch candidate mapping', () => { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: FULL_KEY, ANTHROPIC_MODEL: 'gemini-3.6-flash-high[1M]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gemini-3.6-pro:1m', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3.6-flash-lite', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.6-flash-high:1m', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.6-pro[1m]', @@ -1103,12 +1105,14 @@ describe('cc-switch candidate mapping', () => { expect(candidate.models).toEqual({ main: 'gemini-3.6-flash-high', + fable: 'gemini-3.6-pro', haiku: 'gemini-3.6-flash-lite', sonnet: 'gemini-3.6-flash-high', opus: 'gemini-3.6-pro', }) expect(candidate.model1mSupport).toEqual({ main: true, + fable: true, haiku: false, sonnet: true, opus: true, diff --git a/src/server/services/ccSwitchImport.ts b/src/server/services/ccSwitchImport.ts index 7c4cece4..b37b3108 100644 --- a/src/server/services/ccSwitchImport.ts +++ b/src/server/services/ccSwitchImport.ts @@ -624,6 +624,7 @@ function stripModel1mMarkers(models: ModelMapping): ModelMapping { function readModel1mSupport(models: ModelMapping): Model1mSupport { return { main: hasModel1mMarker(models.main), + fable: models.fable ? hasModel1mMarker(models.fable) : false, haiku: hasModel1mMarker(models.haiku), sonnet: hasModel1mMarker(models.sonnet), opus: hasModel1mMarker(models.opus), diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts index c199c902..1f73c795 100644 --- a/src/server/services/providerRuntimeEnv.ts +++ b/src/server/services/providerRuntimeEnv.ts @@ -176,6 +176,7 @@ function normalizeModel1mSupport( if (!model1mSupport) return undefined const normalized = { main: model1mSupport.main === true, + fable: model1mSupport.fable === true, haiku: model1mSupport.haiku === true, sonnet: model1mSupport.sonnet === true, opus: model1mSupport.opus === true, @@ -209,7 +210,9 @@ function applyModel1mSupportMapping( ): SavedProvider['models'] { return { main: applyModel1mSupport(models.main, model1mSupport?.main), - ...(models.fable ? { fable: models.fable.trim() } : {}), + ...(models.fable !== undefined + ? { fable: applyModel1mSupport(models.fable, model1mSupport?.fable) } + : {}), haiku: applyModel1mSupport(models.haiku, model1mSupport?.haiku), sonnet: applyModel1mSupport(models.sonnet, model1mSupport?.sonnet), opus: applyModel1mSupport(models.opus, model1mSupport?.opus), diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts index f4f3bb90..95a61f88 100644 --- a/src/server/services/teamService.ts +++ b/src/server/services/teamService.ts @@ -269,7 +269,15 @@ function carryForwardArchivedMembers( const identity = memberArchiveIdentity(historicalMember) historicalIdentities.add(identity) const currentMember = currentByIdentity.get(identity) - if (currentMember) return { ...historicalMember, ...currentMember } + if (currentMember) { + return { + ...historicalMember, + ...currentMember, + ...(currentMember.model === undefined && historicalMember.model !== undefined + ? { model: historicalMember.model } + : {}), + } + } return { ...historicalMember, status: historicalMember.status === 'failed' @@ -1515,7 +1523,7 @@ export class TeamService { agentId: m.agentId, name: m.name, agentType: m.agentType, - model: m.model, + ...(m.model ? { model: m.model } : {}), color: m.color, backendType: m.backendType, status: this.deriveStatus(m.isActive), diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index e6509611..ef401e2a 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -55,6 +55,9 @@ export const ModelMappingSchema = z.object({ export const Model1mSupportSchema = z.object({ main: z.boolean(), + // Defaulted rather than required: providers saved before the Fable slot + // existed carry no `fable` key, and a required field would reject them. + fable: z.boolean().default(false), haiku: z.boolean(), sonnet: z.boolean(), opus: z.boolean(), diff --git a/src/utils/teamDiscovery.ts b/src/utils/teamDiscovery.ts index 454c142e..a23b3f5e 100644 --- a/src/utils/teamDiscovery.ts +++ b/src/utils/teamDiscovery.ts @@ -59,7 +59,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] { name: member.name, agentId: member.agentId, agentType: member.agentType, - model: member.model, + ...(member.model ? { model: member.model } : {}), prompt: member.prompt, status, color: member.color,