fix(smithy): spawn honors workspace defaultModels at session start#113
fix(smithy): spawn honors workspace defaultModels at session start#113komoreka wants to merge 2 commits into
Conversation
The session manager's resolveExecutablePath had a 3-tier fallback
(agent metadata → workspace defaults → provider built-in) but model
resolution skipped the middle tier. Agents with no per-agent model
override fell straight to the SDK default (sonnet for Claude Code)
regardless of the workspace's "default model" setting in the UI.
Concretely: every time the operator restarted sf serve smithy, the
director's claude session reverted to sonnet despite the UI showing
opus. They had to /model opus manually each time.
Three pieces:
1. Extend ServerAgentDefaults with defaultModels and defaultProvider.
Persisted server-side via the existing /api/settings/agent-defaults
endpoint. Validated on read and write. Backwards compatible with
workspaces that have agent-defaults set via just defaultExecutablePaths.
2. New resolveModel(providerName, agentModel?) helper on
SessionManagerImpl, mirroring resolveExecutablePath. Used in both
startSession and resumeSession so fresh starts and reconnects honor
the same precedence:
1. options.model (call-site override)
2. agent metadata model
3. workspace defaults defaultModels[providerName]
4. undefined → provider/SDK built-in default
3. Migrate useAgentDefaultsSettings in smithy-web from localStorage-only
to server-backed (mirrors useExecutablePathSettings). The UI now
round-trips defaultProvider + defaultModels through the daemon, so
operator preference reaches the spawn path.
Tests cover all four resolution tiers, per-provider keying (codex
default doesn't shadow claude-code agents), the no-settingsService
fallback, and persistence through getAgentDefaults/setAgentDefaults.
The CLI handler for `sf agent start` calls spawner.spawn() directly, bypassing session-manager.startSession where the resolveModel helper lives. So manual `sf agent start <id>` invocations didn't pick up the workspace defaultModels fallback even after the rest of this PR. Daemon-driven dispatches went through session-manager and worked. Mirror the same precedence chain in the CLI: 1. --model flag 2. agent metadata.model 3. workspace defaults defaultModels[providerName] 4. undefined → provider/SDK built-in default Settings are read directly from the workspace's .stoneforge/stoneforge.db via createSettingsService — no daemon round-trip needed since the CLI runs in the workspace cwd anyway. Verified live: ran `sf agent start <worker> --mode interactive` against a workspace with defaultModels.claude-code='claude-opus-4-5-20251101'; ps showed the spawned claude process invoked with --model claude-opus-4-5-20251101. The director's auto-resume after daemon restart was also captured with the same --model flag, confirming both paths now honor the workspace default.
|
Cross-reference: this PR has light overlap with issue #112 (cross-provider fallbackChain). They touch some of the same files (
This PR works around #112's Part B (full-replace bug) on the client side: the migrated No conflict with PR #110 ( Happy to bundle a small server-side fix for #112's Part B into this PR if maintainers prefer fewer round-trips, or keep them separate for cleaner review. |
Summary
session-manager.resolveExecutablePathhas a 3-tier precedence chain (agent metadata → workspace defaults → provider built-in) but model resolution skipped the middle tier — it only readagent.metadata.model, never the workspace-leveldefaultModels[providerName]setting.Two structural gaps reinforced this:
ServerAgentDefaultstype only persisteddefaultExecutablePathsandfallbackChain.defaultModelsanddefaultProviderweren't stored at all.useAgentDefaultsSettingsin smithy-web was localStorage-only. Even if the operator set "Default model = Opus" in the settings UI, the value lived in their browser only — invisible to the daemon.So the UI and the spawn path operated on disjoint state. Operators saw the workspace setting reflected in the UI dropdown but the daemon kept spawning Claude with the SDK's built-in default. Workaround was per-agent overrides via
PATCH /api/agents/<id>for every agent.Fix
Three pieces, mirroring how the existing
resolveExecutablePathalready works for executable paths:Extend
ServerAgentDefaultswithdefaultModelsanddefaultProvider. Persisted server-side via the existing/api/settings/agent-defaultsendpoint with read + write validation. Backwards compatible with workspaces that have agent-defaults set via justdefaultExecutablePaths.New
resolveModel(providerName, agentModel?)helper onSessionManagerImpl. Private method analogous toresolveExecutablePath. Used in bothstartSessionandresumeSessionso fresh starts and reconnects honor the same precedence:options.model(call-site override) winsagent.metadata.modeldefaults.defaultModels[providerName]undefined→ provider/SDK built-in defaultMigrate
useAgentDefaultsSettingsto server-backed. MirrorsuseExecutablePathSettings's pattern: fetch on mount, debounced PUT on change, localStorage as a hint cache only.The CLI handler
sf agent startwas bypassingsession-manager.startSessionand callingspawner.spawndirectly, so the fix is also wired into the CLI handler (second commit).Files changed
packages/smithy/src/services/settings-service.ts— type extension + read/write validationpackages/smithy/src/services/settings-service.bun.test.ts— 7 new tests covering field persistence and validationpackages/smithy/src/server/routes/settings.ts— accepts and validates the new fields on PUTpackages/smithy/src/runtime/session-manager.ts—resolveModelhelper, used in both spawn pathspackages/smithy/src/runtime/session-manager.bun.test.ts— 6 new tests covering all resolution tierspackages/smithy/src/cli/commands/agent.ts—sf agent startCLI also reads workspace defaultsapps/smithy-web/src/api/hooks/useSettings.ts— server-backed migration of the hookLive verification
Captured during local dogfooding:
Both paths now pass
--modelfrom the workspace default. Inside the resulting director session,/modelshows Opus (claude binary recognizes theopusalias).Test plan
bun test src/services/settings-service.bun.test.ts— 33 passbun test src/runtime/session-manager.bun.test.ts -t 'model resolution'— 6 passbun test src/runtime/session-manager.bun.test.ts— 89 pass (full file, no regressions)bun test packages/smithy/src— 1582 pass / 0 fail / 19 skipturbo run typecheck— 16/16 passpnpm build— 13/13 passMigration note
Workspaces that previously set "Default model" via the localStorage-only UI will start fresh on first load after this lands (server-side store is empty). One-time disruption — users re-set in the UI and it's now persisted server-side where the daemon can read it.
Known small follow-up
The model registry endpoint (
/api/providers/claude-code/models) returns anid: "default"sentinel meaning "use system default". If a user picks that sentinel in the dropdown, the literal string"default"gets passed to claude via--model default, which the binary doesn't recognize. Either filter out the"default"id at spawn time (passundefinedinstead) or document the IDs the dropdown stores. Out of scope for this PR; happy to file a follow-up.