From c758b50ea478cb39fb62601b9e17d63a63015270 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 1/6] fix(tui): confirm textarea with Ctrl+S and show hint in command bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Enter inside wizard textareas (goal/task/agent descriptions) adds a newline — there was no Linux-friendly way to confirm. Ctrl+Enter is indistinguishable from Enter in most Linux terminals, so the only confirm path was unusable. Ctrl+S now confirms any textarea step in FormWizard. It is surfaced in the bottom CommandBar, which switches to step-specific hints while a wizard is open (bold amber Ctrl+S for textareas, step-appropriate hints for text/select/multiselect). Co-Authored-By: Claude Opus 4.7 --- src/tui/App.tsx | 3 ++ src/tui/components/CommandBar.tsx | 65 +++++++++++++++++++++++++++++- src/tui/components/FormWizard.tsx | 16 +++++--- test/unit/tui/form-wizard.test.tsx | 48 +++++++++++++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4788d27..22b5be3 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -419,6 +419,7 @@ export function App({ const inputHook = useTextInput(); const inputValue = inputHook.value; const [wizardConfig, setWizardConfig] = useState(null); + const [wizardStepType, setWizardStepType] = useState<'text' | 'select' | 'textarea' | 'multiselect' | null>(null); /** Temp file paths for images pasted via Ctrl+I during task wizard */ const [pendingAttachments, setPendingAttachments] = useState([]); @@ -2521,6 +2522,7 @@ export function App({ height={feedH} onPasteImage={isPasteCapable ? handlePasteImage : undefined} onSuggestionSelected={wizardConfig.kind === 'agent' ? handleSuggestionSelected : undefined} + onStepChange={(step) => setWizardStepType(step ? step.type : null)} footerExtra={ pendingAttachments.length > 0 && isPasteCapable ? `\uD83D\uDCCE${pendingAttachments.length}` : undefined @@ -2627,6 +2629,7 @@ export function App({ width={W} hasSuggestions={showSuggestions} onboardingCompleted={initialState.onboardingCompleted} + wizardStepType={inputMode === 'wizard' ? wizardStepType : null} /> ); diff --git a/src/tui/components/CommandBar.tsx b/src/tui/components/CommandBar.tsx index 37ac85b..428ddd8 100644 --- a/src/tui/components/CommandBar.tsx +++ b/src/tui/components/CommandBar.tsx @@ -38,11 +38,13 @@ export interface CommandBarProps { width: number; hasSuggestions?: boolean; onboardingCompleted?: boolean; + /** When a wizard is open, render a wizard-specific hint row instead of the app nav hints. */ + wizardStepType?: 'text' | 'select' | 'textarea' | 'multiselect' | null; } export const CommandBar = React.memo(function CommandBar({ mode, value, completion, activeView, canRun, canNew, canApprove, canReject, canCancel, canDelete, canUndo, canEdit, canForceStop, canToggleAuto, autoActive, canPause, isPaused, canToggleShowAll, showAllActive, hasDetail, - itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, + itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, wizardStepType, }: CommandBarProps) { if (mode === 'command') { const hintText = hasSuggestions @@ -64,6 +66,67 @@ export const CommandBar = React.memo(function CommandBar({ ); } + // Wizard mode — show step-specific hints (overrides the app nav hints) + if (wizardStepType) { + const hint = + wizardStepType === 'textarea' ? ( + <> + Ctrl+S + save description + {' '} + Enter + newline + {' '} + {'\u2190\u2191\u2192\u2193'} + navigate + {' '} + Esc + back + + ) : wizardStepType === 'select' ? ( + <> + {'\u2191\u2193'} + select + {' '} + Enter + confirm + {' '} + Esc + back + + ) : wizardStepType === 'multiselect' ? ( + <> + {'\u2191\u2193'} + move + {' '} + Space + toggle + {' '} + Enter + confirm + {' '} + Esc + back + + ) : ( + <> + Enter + confirm + {' '} + {'\u2190\u2192'} + move + {' '} + Esc + back + + ); + return ( + + {hint} + + ); + } + // Navigate mode — hotkey hints return ( diff --git a/src/tui/components/FormWizard.tsx b/src/tui/components/FormWizard.tsx index f095572..583dd33 100644 --- a/src/tui/components/FormWizard.tsx +++ b/src/tui/components/FormWizard.tsx @@ -55,6 +55,8 @@ export interface FormWizardProps { footerExtra?: string; /** Called when user selects a suggestion from a text step's suggestion list */ onSuggestionSelected?: (suggestionValue: string) => void; + /** Called whenever the active step changes, so parent can reflect step type in the bottom bar. */ + onStepChange?: (step: WizardStep | null) => void; } const CURSOR = '\u2588'; // █ (used by textarea render) @@ -98,8 +100,12 @@ function wordBoundaryForward(text: string, pos: number): number { return i; } -export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected }: FormWizardProps) { +export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected, onStepChange }: FormWizardProps) { const [currentStep, setCurrentStep] = useState(0); + useEffect(() => { + onStepChange?.(steps[currentStep] ?? null); + return () => { onStepChange?.(null); }; + }, [currentStep, steps, onStepChange]); const [values, setValues] = useState>({}); // Unified text input for single-line text steps (replaces textInput + cursorPos). @@ -434,8 +440,8 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, } if (step.type === 'textarea') { - // Ctrl+Enter / Cmd+Enter: confirm textarea - if (key.return && (key.ctrl || key.meta)) { + // Ctrl+Enter / Cmd+Enter / Ctrl+S: confirm textarea + if ((key.return && (key.ctrl || key.meta)) || (key.ctrl && input === 's')) { const val = taLines.join('\n').trim(); if (step.required && !val) { setDirty(true); return; } if (validationError !== null) { @@ -728,7 +734,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, {step.label} {step.required && *} - {!step.required && (optional, {step.type === 'textarea' ? `${CMD_KEY}+Enter` : 'Enter'} to skip)} + {!step.required && (optional, {step.type === 'textarea' ? `Ctrl+S` : 'Enter'} to skip)} {/* Step description / guidance */} @@ -899,7 +905,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, : step.type === 'multiselect' ? '\u2191\u2193 move Space toggle Enter confirm' : step.type === 'textarea' - ? `Enter newline ${CMD_KEY}+Enter confirm \u2190\u2191\u2192\u2193 navigate` + ? `Ctrl+S save Enter newline \u2190\u2191\u2192\u2193 navigate` : browsingSuggestions ? '\u2191\u2193 browse Enter select \u2191 back to input' : step.suggestions diff --git a/test/unit/tui/form-wizard.test.tsx b/test/unit/tui/form-wizard.test.tsx index 7b0a101..5b48e2a 100644 --- a/test/unit/tui/form-wizard.test.tsx +++ b/test/unit/tui/form-wizard.test.tsx @@ -109,7 +109,7 @@ describe('FormWizard textarea', () => { await delay(50); const output = lastFrame()!; expect(output).toContain('Enter newline'); - expect(output).toContain('Enter confirm'); + expect(output).toContain('Ctrl+S'); expect(output).toContain('navigate'); }); @@ -243,6 +243,52 @@ describe('FormWizard textarea', () => { expect(onComplete).not.toHaveBeenCalled(); }); + /* ── Ctrl+S confirms textarea ── */ + + it('Ctrl+S confirms textarea and calls onComplete', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('Hello world'); + await delay(50); + stdin.write('\x13'); // Ctrl+S to confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: 'Hello world' }); + }); + + it('Ctrl+S confirms empty optional textarea', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('\x13'); // Ctrl+S on empty optional field should confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: '' }); + }); + /* ── Cursor navigation ── */ it('←→ arrows move cursor in textarea', async () => { From 44e83564030291ab69b7e39613bab1254523a8f9 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 2/6] fix(tui): cursor at input start; show saved agent on reopen Two small UX fixes: * Empty single-line inputs rendered the cursor after the placeholder, making it look like the cursor was floating mid-line. Render cursor before placeholder so it sits at column 0, matching user expectation. * Reopening the edit-agent wizard immediately after saving showed the pre-save values because refreshAll() is async. Merge the returned agent into liveAgents synchronously in the save callback, and add liveAgents to launchEditAgentWizard's dependency array so the wizard uses the latest snapshot. Co-Authored-By: Claude Opus 4.7 --- src/tui/App.tsx | 5 ++++- src/tui/components/TextInput.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 22b5be3..5030c02 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1010,7 +1010,7 @@ export function App({ targetId: agent.id, }); setInputMode('wizard'); - }, [liveTeams]); + }, [liveTeams, liveAgents]); const launchConfigWizard = useCallback(() => { setWizardConfig({ @@ -1123,6 +1123,9 @@ export function App({ onUpdateAgent(targetId, { name: fields.name, role: fields.role, model: fields.model, effort: fields.effort }).then( (agent) => { addMessage(`\u2713 Updated agent "${agent.name}"`, tuiColors.green); + // Optimistically merge returned agent into local state so reopening the + // editor immediately after save shows fresh values without waiting for refreshAll. + setLiveAgents((prev) => prev.map((a) => a.id === agent.id ? agent : a)); // Handle team change const teamOps: Promise[] = []; if (oldTeamId && oldTeamId !== newTeamId && onLeaveTeam) { diff --git a/src/tui/components/TextInput.tsx b/src/tui/components/TextInput.tsx index b786414..35d0e80 100644 --- a/src/tui/components/TextInput.tsx +++ b/src/tui/components/TextInput.tsx @@ -116,8 +116,8 @@ export function TextInput({ return ( {prefixStr && {prefixStr}} - {placeholder && {placeholder}} {showCursor && {CURSOR_CHAR}} + {placeholder && {placeholder}} ); } From 9c2caea109c1612593fd99aaade4eaa4c25cffef Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 3/6] chore(scripts): add dev-link and dev-unlink helpers Convenience scripts for testing a local build against the globally-installed orch binary. dev-link.sh builds and npm-links the fork; dev-unlink.sh restores the global install. Co-Authored-By: Claude Opus 4.7 --- scripts/dev-link.sh | 8 ++++++++ scripts/dev-unlink.sh | 4 ++++ 2 files changed, 12 insertions(+) create mode 100755 scripts/dev-link.sh create mode 100755 scripts/dev-unlink.sh diff --git a/scripts/dev-link.sh b/scripts/dev-link.sh new file mode 100755 index 0000000..ea9f4ba --- /dev/null +++ b/scripts/dev-link.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/.." +echo "Building..." +npm run build +echo "Linking..." +npm link +echo "Done — 'orch' now points to local build." diff --git a/scripts/dev-unlink.sh b/scripts/dev-unlink.sh new file mode 100755 index 0000000..6122840 --- /dev/null +++ b/scripts/dev-unlink.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +npm unlink -g @oxgeneral/orch +echo "Done — 'orch' restored to globally installed version." From 635914c71e37c92e7af9e06063e590914c754eff Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:10:41 +0000 Subject: [PATCH 4/6] feat(models): add Claude Opus 4.7 to tier map and model catalogs Co-Authored-By: Claude Sonnet 4.6 --- src/domain/model-tiers.ts | 2 +- src/tui/wizardConfigs.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/model-tiers.ts b/src/domain/model-tiers.ts index e0b178e..b84caa1 100644 --- a/src/domain/model-tiers.ts +++ b/src/domain/model-tiers.ts @@ -27,7 +27,7 @@ export type ModelTier = 'capable' | 'balanced' | 'fast'; */ export const MODEL_TIER_MAP: Record> = { claude: { - capable: 'claude-opus-4-6', + capable: 'claude-opus-4-7', balanced: 'claude-sonnet-4-6', fast: 'claude-haiku-4-6', }, diff --git a/src/tui/wizardConfigs.ts b/src/tui/wizardConfigs.ts index 984f12b..5641f89 100644 --- a/src/tui/wizardConfigs.ts +++ b/src/tui/wizardConfigs.ts @@ -19,6 +19,7 @@ import { isMcpSkill } from '../application/agent-factory.js'; // ── Model catalogs per adapter ── const CLAUDE_MODELS = [ + { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', hint: 'most capable' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, { value: 'claude-haiku-4-6', label: 'Claude Haiku 4.6', hint: 'fastest, cheapest' }, @@ -48,6 +49,7 @@ const CURSOR_MODELS = [ const OPENCODE_MODELS = [ { value: '', label: 'Default', hint: 'use model configured in opencode' }, + { value: 'openrouter/anthropic/claude-opus-4.7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, { value: 'openrouter/anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, { value: 'openrouter/anthropic/claude-opus-4.6', label: 'Claude Opus 4.6', hint: 'most capable' }, { value: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro', hint: 'Google' }, From 20a212dbad839bc16f3a58baea97328322c0b5e7 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:10:46 +0000 Subject: [PATCH 5/6] fix(orchestrator): resolve model tier aliases before adapter dispatch Agents whose YAML stored a semantic tier name (capable/balanced/fast) instead of a concrete model string would pass the alias directly to the adapter CLI (e.g. --model balanced), causing a 404 from the API. Now resolves any tier alias to the adapter-specific model string at dispatch time, making legacy YAML files safe without requiring migration. Co-Authored-By: Claude Sonnet 4.6 --- src/application/orchestrator.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/application/orchestrator.ts b/src/application/orchestrator.ts index 900539c..8e22a64 100644 --- a/src/application/orchestrator.ts +++ b/src/application/orchestrator.ts @@ -23,6 +23,7 @@ import { } from '../domain/transitions.js'; import { NoAgentsError, TaskAlreadyRunningError, LockConflictError, WorkspaceError, classifyAdapterError } from '../domain/errors.js'; import { scopesOverlap, ScopeIndex } from '../domain/scope.js'; +import { isModelTier, resolveModel } from '../domain/model-tiers.js'; import { acquireLock, releaseLock, touchLock } from '../infrastructure/storage/lock.js'; import type { ITaskStore, IAgentStore, IRunStore, IStateStore, IContextStore, IGoalStore } from '../infrastructure/storage/interfaces.js'; import { CachedTaskStore, CachedAgentStore, CachedGoalStore } from '../infrastructure/storage/cached-stores.js'; @@ -1097,6 +1098,12 @@ export class Orchestrator { const abortController = new AbortController(); this.abortControllers.set(taskId, abortController); + // Resolve semantic tier aliases (capable/balanced/fast) → concrete model strings + const resolvedConfig = { ...agentData.config }; + if (resolvedConfig.model && isModelTier(resolvedConfig.model)) { + resolvedConfig.model = resolveModel(agent.adapter, resolvedConfig.model) || undefined; + } + const handle = adapter.execute({ prompt, systemPrompt, @@ -1107,7 +1114,7 @@ export class Orchestrator { ORCH_AGENT_NAME: agent.name, ORCH_TASK_ID: task.id, }, - config: agentData.config, + config: resolvedConfig, signal: abortController.signal, }); From c9a723639e5b28e85d7d3da65c3ee12c2ccaa709 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:19:03 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(models):=20address=20PR=20#10=20review?= =?UTF-8?q?=20=E2=80=94=20tests,=20opencode=20tier,=20tier=20aliases=20in?= =?UTF-8?q?=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model-tiers: update opencode.capable to claude-opus-4.7 (matches wizardConfigs) - wizardConfigs: rename opus 4.6 hints 'most capable' → 'previous flagship' so there is one clear top choice; add capable/balanced/fast tier aliases as wizard options so users can opt into live tier resolution without hand-editing YAML - model-tiers.test.ts: update claude capable assertion to 4-7 (was failing); add explicit named assertion so a future bump is impossible to miss - orchestrator-tier-aliases.test.ts: 4 new tests covering capable/balanced/fast alias resolution and concrete model pass-through at dispatchTask time Co-Authored-By: Claude Sonnet 4.6 --- src/domain/model-tiers.ts | 2 +- src/tui/wizardConfigs.ts | 7 +- .../orchestrator-tier-aliases.test.ts | 160 ++++++++++++++++++ test/unit/domain/model-tiers.test.ts | 4 +- 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 test/unit/application/orchestrator-tier-aliases.test.ts diff --git a/src/domain/model-tiers.ts b/src/domain/model-tiers.ts index b84caa1..ea204d7 100644 --- a/src/domain/model-tiers.ts +++ b/src/domain/model-tiers.ts @@ -32,7 +32,7 @@ export const MODEL_TIER_MAP: Record> = { fast: 'claude-haiku-4-6', }, opencode: { - capable: 'openrouter/anthropic/claude-opus-4.6', + capable: 'openrouter/anthropic/claude-opus-4.7', balanced: '', fast: 'openrouter/google/gemini-2.5-flash', }, diff --git a/src/tui/wizardConfigs.ts b/src/tui/wizardConfigs.ts index 5641f89..3542153 100644 --- a/src/tui/wizardConfigs.ts +++ b/src/tui/wizardConfigs.ts @@ -19,8 +19,11 @@ import { isMcpSkill } from '../application/agent-factory.js'; // ── Model catalogs per adapter ── const CLAUDE_MODELS = [ + { value: 'capable', label: 'Capable tier (auto)', hint: 'always resolves to latest flagship' }, + { value: 'balanced', label: 'Balanced tier (auto)', hint: 'always resolves to latest sonnet' }, + { value: 'fast', label: 'Fast tier (auto)', hint: 'always resolves to latest haiku' }, { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, - { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', hint: 'most capable' }, + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', hint: 'previous flagship' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, { value: 'claude-haiku-4-6', label: 'Claude Haiku 4.6', hint: 'fastest, cheapest' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', hint: 'extended thinking' }, @@ -51,7 +54,7 @@ const OPENCODE_MODELS = [ { value: '', label: 'Default', hint: 'use model configured in opencode' }, { value: 'openrouter/anthropic/claude-opus-4.7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, { value: 'openrouter/anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, - { value: 'openrouter/anthropic/claude-opus-4.6', label: 'Claude Opus 4.6', hint: 'most capable' }, + { value: 'openrouter/anthropic/claude-opus-4.6', label: 'Claude Opus 4.6', hint: 'previous flagship' }, { value: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro', hint: 'Google' }, { value: 'openrouter/google/gemini-2.5-flash', label: 'Gemini 2.5 Flash', hint: 'Google, fast' }, { value: 'openrouter/deepseek/deepseek-v3.2', label: 'DeepSeek V3.2', hint: 'open-source' }, diff --git a/test/unit/application/orchestrator-tier-aliases.test.ts b/test/unit/application/orchestrator-tier-aliases.test.ts new file mode 100644 index 0000000..f6343fa --- /dev/null +++ b/test/unit/application/orchestrator-tier-aliases.test.ts @@ -0,0 +1,160 @@ +/** + * Tests that semantic tier aliases (capable/balanced/fast) stored in agent YAML + * are resolved to concrete model strings before adapter.execute() is called. + * + * This is intentional behaviour (option b): agents can store a tier alias so + * they automatically ride the latest flagship whenever MODEL_TIER_MAP is bumped, + * without requiring a migration of every agent YAML file. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Orchestrator } from '../../../src/application/orchestrator.js'; +import { AdapterRegistry } from '../../../src/infrastructure/adapters/registry.js'; +import type { IAgentAdapter, ExecuteHandle, AgentEvent } from '../../../src/infrastructure/adapters/interface.js'; +import { MODEL_TIER_MAP } from '../../../src/domain/model-tiers.js'; +import { + makeTask, + makeAgent, + createMockTaskStore, + createMockAgentStore, + buildDeps, +} from './helpers.js'; + +vi.mock('../../../src/infrastructure/storage/lock.js', () => ({ + acquireLock: vi.fn(async () => ({ acquired: true, pid: process.pid })), + releaseLock: vi.fn(async () => {}), + touchLock: vi.fn(async () => {}), +})); + +function createCapturingAdapter(): { + adapter: IAgentAdapter; + getLastConfig: () => Record | undefined; +} { + let lastConfig: Record | undefined; + + const adapter: IAgentAdapter = { + kind: 'claude', + test: vi.fn(async () => ({ ok: true })), + execute: vi.fn((params): ExecuteHandle => { + lastConfig = params.config as Record; + return { + pid: 11111, + events: (async function* (): AsyncGenerator { + yield { type: 'output', data: 'done' }; + })(), + }; + }), + stop: vi.fn(async () => {}), + }; + + return { adapter, getLastConfig: () => lastConfig }; +} + +function waitForDispatch(ms = 300): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('Orchestrator tier alias resolution at dispatch', () => { + let orchestrator: Orchestrator; + + afterEach(() => { + const o = orchestrator as any; + if (o.intervalId) { clearInterval(o.intervalId); o.intervalId = null; } + if (o.immediateDispatchTimer) { clearTimeout(o.immediateDispatchTimer); o.immediateDispatchTimer = null; } + if (o.saveStateTimer) { clearTimeout(o.saveStateTimer); o.saveStateTimer = null; } + o.shuttingDown = true; + o.removeSignalHandlers?.(); + o.lockAcquired = false; + }); + + it('resolves "capable" tier alias to the current claude flagship before dispatch', async () => { + const task = makeTask({ status: 'todo' }); + const agent = makeAgent({ adapter: 'claude', config: { approval_policy: 'auto', model: 'capable' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + const config = getLastConfig(); + expect(config).not.toBeUndefined(); + expect(config!['model']).toBe(MODEL_TIER_MAP.claude.capable); + expect(config!['model']).toBe('claude-opus-4-7'); + }); + + it('resolves "balanced" tier alias before dispatch', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_bal' }); + const agent = makeAgent({ id: 'agt_bal', adapter: 'claude', config: { approval_policy: 'auto', model: 'balanced' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe(MODEL_TIER_MAP.claude.balanced); + }); + + it('resolves "fast" tier alias before dispatch', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_fast' }); + const agent = makeAgent({ id: 'agt_fast', adapter: 'claude', config: { approval_policy: 'auto', model: 'fast' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe(MODEL_TIER_MAP.claude.fast); + }); + + it('passes concrete model strings through unchanged', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_concrete' }); + const agent = makeAgent({ + id: 'agt_concrete', + adapter: 'claude', + config: { approval_policy: 'auto', model: 'claude-sonnet-4-6' }, + }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/test/unit/domain/model-tiers.test.ts b/test/unit/domain/model-tiers.test.ts index 736c055..067753c 100644 --- a/test/unit/domain/model-tiers.test.ts +++ b/test/unit/domain/model-tiers.test.ts @@ -9,8 +9,8 @@ import { } from '../../../src/domain/model-tiers.js'; describe('resolveModel', () => { - it('claude capable → opus', () => { - expect(resolveModel('claude', 'capable')).toBe('claude-opus-4-6'); + it('claude capable → opus 4.7 (current flagship)', () => { + expect(resolveModel('claude', 'capable')).toBe('claude-opus-4-7'); }); it('claude balanced → sonnet', () => {