From 4c1e6e039eb4d0940a46b66e3b74268e51184286 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:57 +0800 Subject: [PATCH] Unify Workspace agent defaults in runtime settings --- docs/model-semantics-and-runtime-injection.md | 7 +- src/core/config.ts | 2 +- .../0042_workspace_default_agent.spec.ts | 51 +++++++++++++ .../0042_workspace_default_agent/index.ts | 72 +++++++++++++++++++ src/migrations/INDEX.md | 1 + src/migrations/registry.spec.ts | 4 +- src/migrations/registry.ts | 4 +- src/webui/routes/inquiries.spec.ts | 2 +- src/webui/routes/workspaces-quickchat.spec.ts | 6 +- src/webui/routes/workspaces.spec.ts | 22 ++---- src/webui/routes/workspaces.ts | 25 +++---- src/workspaces/conversation-control.spec.ts | 9 ++- src/workspaces/conversation-control.ts | 2 +- src/workspaces/service.ts | 30 +++++--- src/workspaces/workspace-metadata.spec.ts | 6 +- src/workspaces/workspace-metadata.ts | 6 -- ui/src/components/workspace/api.ts | 3 +- ui/src/contexts/WorkspacesContext.tsx | 2 +- ui/src/contexts/workspaces-context.ts | 2 +- ui/src/demo/handlers/workspaces.ts | 13 ++-- 20 files changed, 189 insertions(+), 80 deletions(-) create mode 100644 src/migrations/0042_workspace_default_agent.spec.ts create mode 100644 src/migrations/0042_workspace_default_agent/index.ts diff --git a/docs/model-semantics-and-runtime-injection.md b/docs/model-semantics-and-runtime-injection.md index 026ea54cc..14a3c79c5 100644 --- a/docs/model-semantics-and-runtime-injection.md +++ b/docs/model-semantics-and-runtime-injection.md @@ -233,8 +233,11 @@ the Workspace sidebar, and interactive CLI/API starts use `interactive`; Issues, schedules, automation, and headless CLI/API starts use `headless`. An explicit Quick Chat, sidebar, Issue, CLI, or API runtime choice wins for that one Session. Otherwise OpenAlice uses the mode's fixed Agent, then its recent -Agent, then the legacy `.alice/workspace.json` `defaultAgent`, then the -installation-wide `workspaceDefaultAgent`. If none resolves to a registered +Agent, then the installation-wide `workspaceDefaultAgent`. Headless dispatch +first uses its mode defaults, then `issueDefaultAgent`, then the interactive +fallback. `.alice/workspace.json` contains display metadata only; migration +0042 moves its shipped `defaultAgent` to the interactive fixed default without +overwriting an existing fixed default. If none resolves to a registered Agent runtime, Alice falls back to the first registered runtime. Headless mode defaults must resolve to a headless-capable Agent. diff --git a/src/core/config.ts b/src/core/config.ts index 53b7a5b01..be4c76b86 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -224,7 +224,7 @@ export const aiProviderSchema = z.object({ workspaceCredentialDefaults: z.record(z.string(), workspaceCredentialDefaultSchema).default({}), /** * Installation-level fallback for a fresh interactive Session when its - * Workspace has no `.alice/workspace.json` defaultAgent. Explicit launch + * Workspace has no `.alice/settings.json` interactive preference. Explicit launch * choices still win and must not rewrite this value. Shell is a utility * adapter, not a valid stored default. */ diff --git a/src/migrations/0042_workspace_default_agent.spec.ts b/src/migrations/0042_workspace_default_agent.spec.ts new file mode 100644 index 000000000..17dcf3089 --- /dev/null +++ b/src/migrations/0042_workspace_default_agent.spec.ts @@ -0,0 +1,51 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { emptyWorkspaceRuntimeSettings } from '../workspaces/workspace-runtime-settings.js' +import { migrateWorkspaceDefaultAgent } from './0042_workspace_default_agent/index.js' + +let root: string +let dir: string +const read = async (path: string) => JSON.parse(await readFile(path, 'utf8')) +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'workspace-default-migration-')) + dir = join(root, 'workspace', '.alice') + await mkdir(dir, { recursive: true }) + await mkdir(join(root, 'state')) + await writeFile(join(root, 'state', 'workspace-catalog.json'), JSON.stringify({ + version: 1, workspaces: [{ activeDir: join(root, 'workspace'), lifecycle: 'active' }], + })) + await writeFile(join(dir, 'workspace.json'), JSON.stringify({ displayName: 'Research', defaultAgent: 'codex' })) +}) +afterEach(async () => { await rm(root, { recursive: true, force: true }) }) + +describe('Workspace default Agent migration', () => { + it('moves the old default, preserves display metadata, and is idempotent', async () => { + await migrateWorkspaceDefaultAgent(root) + const settings = await read(join(dir, 'settings.json')) + expect(settings.runtime.interactive.defaultAgent).toBe('codex') + expect(settings.runtime.headless.defaultAgent).toBeUndefined() + expect(await read(join(dir, 'workspace.json'))).toEqual({ displayName: 'Research' }) + await migrateWorkspaceDefaultAgent(root) + expect(await read(join(dir, 'settings.json'))).toEqual(settings) + }) + + it('preserves explicit defaults, model preferences, and recent history', async () => { + const settings = emptyWorkspaceRuntimeSettings() + settings.runtime.interactive.defaultAgent = 'claude' + settings.runtime.interactive.recent.agent = 'pi' + settings.runtime.headless.defaultAgent = 'codex' + settings.runtime.headless.agents.codex = { accessMode: 'native', model: 'gpt-5.6-sol', reasoningEffort: 'medium' } + await writeFile(join(dir, 'settings.json'), JSON.stringify(settings)) + await migrateWorkspaceDefaultAgent(root) + expect(await read(join(dir, 'settings.json'))).toEqual(settings) + expect(await read(join(dir, 'workspace.json'))).toEqual({ displayName: 'Research' }) + }) + + it('does not discard the old choice when the destination is invalid', async () => { + await writeFile(join(dir, 'settings.json'), '{broken') + await expect(migrateWorkspaceDefaultAgent(root)).rejects.toThrow() + expect((await read(join(dir, 'workspace.json'))).defaultAgent).toBe('codex') + }) +}) diff --git a/src/migrations/0042_workspace_default_agent/index.ts b/src/migrations/0042_workspace_default_agent/index.ts new file mode 100644 index 000000000..4d1ce7f86 --- /dev/null +++ b/src/migrations/0042_workspace_default_agent/index.ts @@ -0,0 +1,72 @@ +import { readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { z } from 'zod' + +import type { Migration } from '../types.js' + +// Snapshot only this migration's destination boundary, independent of runtime +// config loading and future schema changes. Preserve all other preference fields. +const modeSchema = z.object({ defaultAgent: z.string().trim().min(1).max(64).optional() }).passthrough().default({}) +const settingsSchema = z.object({ + version: z.literal(3), + runtime: z.object({ interactive: modeSchema, headless: modeSchema }).passthrough().default({ interactive: {}, headless: {} }), +}).passthrough() +const emptySettings = () => ({ + version: 3 as const, + runtime: { + interactive: { agents: {}, recent: { agents: {} } }, + headless: { agents: {}, recent: { agents: {} } }, + }, +}) + +async function readJson(path: string): Promise | undefined> { + try { + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`invalid object: ${path}`) + return value as Record + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } +} + +async function writeJson(path: string, value: unknown): Promise { + const temporary = `${path}.${process.pid}.tmp` + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`) + await rename(temporary, path) +} + +export async function migrateWorkspaceDefaultAgent(launcherRoot: string): Promise { + const catalog = await readJson(join(launcherRoot, 'state', 'workspace-catalog.json')) + if (!catalog || catalog.version !== 1 || !Array.isArray(catalog.workspaces)) return + for (const row of catalog.workspaces) { + if (!row || typeof row !== 'object') continue + if (row.lifecycle === 'purged' || row.lifecycle === 'purging' || typeof row.activeDir !== 'string') continue + const metadataPath = join(row.activeDir, '.alice', 'workspace.json') + const metadata = await readJson(metadataPath) + if (!metadata || !Object.hasOwn(metadata, 'defaultAgent')) continue + const agent = metadata.defaultAgent + if (typeof agent !== 'string' || !agent.trim()) throw new Error(`invalid defaultAgent: ${metadataPath}`) + const settingsPath = join(row.activeDir, '.alice', 'settings.json') + const raw = await readJson(settingsPath) + const settings = settingsSchema.parse(raw ?? emptySettings()) + // Fixed mode defaults win over the old generic fallback. Save the destination + // first so an interrupted migration can safely resume without losing a choice. + if (!settings.runtime.interactive.defaultAgent) { + settings.runtime.interactive.defaultAgent = agent.trim() + await writeJson(settingsPath, settings) + } + delete metadata.defaultAgent + await writeJson(metadataPath, metadata) + } +} + +export const migration: Migration = { + id: '0042_workspace_default_agent', + appVersion: '0.92.1', + introducedAt: '2026-09-10', + affects: ['workspaces/*/.alice/workspace.json', 'workspaces/*/.alice/settings.json'], + summary: 'Move the legacy Workspace default Agent into interactive runtime settings, preserving pinned defaults and Session bindings.', + up: async (ctx) => migrateWorkspaceDefaultAgent(ctx.launcherRoot()), +} diff --git a/src/migrations/INDEX.md b/src/migrations/INDEX.md index 09daafb45..4a8259e44 100644 --- a/src/migrations/INDEX.md +++ b/src/migrations/INDEX.md @@ -14,3 +14,4 @@ Each row below corresponds to an active migration in `src/migrations/`. The runn | `0039_workspace_session_runtime_bindings` | 0.89.3-beta | 2026-08-11 | workspaces/state/resume-identities.json, workspaces/state/workspace-manager-sessions/*.json, workspaces/workspaces/*/.alice/sessions/*.json, workspaces/departed-workspaces/*/.alice/sessions/*.json | Move 0.89.2 Session AI bindings from the launcher identity registry into their owning Workspaces. | | `0040_unified_session_records` | 0.89.4-beta | 2026-08-15 | workspaces/state/resume-identities.json, workspaces/state/headless-tasks.json, workspaces/state/sessions/*.json | Give every non-purged resume identity one persistent Session roster record, including headless-born conversations. | | `0041_connector_desk_flag` | 0.89.5-beta | 2026-08-19 | workspaces/*/.alice/issues/*.md | Rewrite shipped telegramConnector: true Issue flags to connectorDesk: telegram. | +| `0042_workspace_default_agent` | 0.92.1 | 2026-09-10 | workspaces/*/.alice/workspace.json, workspaces/*/.alice/settings.json | Move the legacy Workspace default Agent into interactive runtime settings, preserving pinned defaults and Session bindings. | diff --git a/src/migrations/registry.spec.ts b/src/migrations/registry.spec.ts index ab9342934..b5632e807 100644 --- a/src/migrations/registry.spec.ts +++ b/src/migrations/registry.spec.ts @@ -11,9 +11,9 @@ import { describe('migration baseline', () => { it('keeps retired development migrations out of the runtime registry', () => { expect(MIGRATION_BASELINE).toBe('0.89.2-beta') - expect(NEXT_MIGRATION_NUMBER).toBe(42) + expect(NEXT_MIGRATION_NUMBER).toBe(43) expect(REGISTRY.map((migration) => Number.parseInt(migration.id.slice(0, 4), 10))) - .toEqual([39, 40, 41]) + .toEqual([39, 40, 41, 42]) }) it('runs unit tests inside one isolated complete home', () => { diff --git a/src/migrations/registry.ts b/src/migrations/registry.ts index f8b4f49c2..5794a15e2 100644 --- a/src/migrations/registry.ts +++ b/src/migrations/registry.ts @@ -14,12 +14,14 @@ import type { Migration } from './types.js' import { migration as migration_0039_workspace_session_runtime_bindings } from './0039_workspace_session_runtime_bindings/index.js' import { migration as migration_0040_unified_session_records } from './0040_unified_session_records/index.js' import { migration as migration_0041_connector_desk_flag } from './0041_connector_desk_flag/index.js' +import { migration as migration_0042_workspace_default_agent } from './0042_workspace_default_agent/index.js' export const MIGRATION_BASELINE = '0.89.2-beta' -export const NEXT_MIGRATION_NUMBER = 42 +export const NEXT_MIGRATION_NUMBER = 43 export const REGISTRY: Migration[] = [ migration_0039_workspace_session_runtime_bindings, migration_0040_unified_session_records, migration_0041_connector_desk_flag, + migration_0042_workspace_default_agent, ] diff --git a/src/webui/routes/inquiries.spec.ts b/src/webui/routes/inquiries.spec.ts index 514566156..4aa3ec0af 100644 --- a/src/webui/routes/inquiries.spec.ts +++ b/src/webui/routes/inquiries.spec.ts @@ -37,7 +37,7 @@ function build(opts: { assignee?: string } = {}) { : undefined, }, config: { launcherRepoRoot: '/tmp/repo' }, - resolveDefaultAgentId: vi.fn(async () => 'pi'), + resolveHeadlessDefaultAgentId: vi.fn(async () => 'pi'), dispatchHeadlessTask, headlessTasks: { list, get: vi.fn() }, headlessLogsDir: '/tmp/missing-inquiry-logs', diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index 0cfef0ee4..87f91cbcf 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -25,7 +25,6 @@ import { TemplateWorkspaceResolver, } from '../../workspaces/chat-workspace-resolver.js'; import { createBuiltinAdapterRegistry } from '../../workspaces/adapters/index.js'; -import { writeWorkspaceMetadata } from '../../workspaces/workspace-metadata.js'; import { emptyWorkspaceRuntimeSettings, readWorkspaceRuntimeSettings, @@ -1036,7 +1035,10 @@ describe('POST /quick-chat — native auth and explicit credential overrides', ( it('omitted agent prefers the target Workspace runtime over the installation default', async () => { const dir = await mkdtemp(join(tmpdir(), 'quick-chat-runtime-')); try { - await writeWorkspaceMetadata(dir, { defaultAgent: 'opencode' }); + const settings = emptyWorkspaceRuntimeSettings(); + settings.runtime.interactive.defaultAgent = 'opencode'; + settings.runtime.interactive.recent.agent = 'claude'; + await writeWorkspaceRuntimeSettings(dir, settings); vi.mocked(readWorkspaceDefaultAgent).mockResolvedValue('claude'); vi.mocked(readCredentials).mockResolvedValue({ 'openai-1': openaiKey }); const workspace = { id: 'ws-1', dir, template: 'chat', tag: 'chat-x' }; diff --git a/src/webui/routes/workspaces.spec.ts b/src/webui/routes/workspaces.spec.ts index b2b0c1643..182391687 100644 --- a/src/webui/routes/workspaces.spec.ts +++ b/src/webui/routes/workspaces.spec.ts @@ -828,26 +828,12 @@ describe('PATCH /:id/metadata', () => { } }); - it('persists a registered Workspace default agent runtime', async () => { + it('rejects runtime preferences in display metadata', async () => { const dir = await mkdtemp(join(tmpdir(), 'workspace-route-runtime-')); try { - const codex = { id: 'codex', capabilities: { headless: true } }; - const { app } = build({ - meta: { id: 'ws-1', tag: 'stable-tag', dir }, - adapters: { codex }, - }); - - const saved = await patch(app, '/ws-1/metadata', { defaultAgent: 'codex' }); - expect(saved.status).toBe(200); - expect(saved.body.workspace.defaultAgent).toBe('codex'); - expect(await readWorkspaceMetadata(dir)).toEqual({ - ok: true, - metadata: { defaultAgent: 'codex' }, - }); - - const cleared = await patch(app, '/ws-1/metadata', { defaultAgent: null }); - expect(cleared.status).toBe(200); - expect(cleared.body.workspace.defaultAgent).toBeUndefined(); + const { app } = build({ meta: { id: 'ws-1', tag: 'stable-tag', dir } }); + expect((await patch(app, '/ws-1/metadata', { defaultAgent: 'codex' })).status).toBe(400); + expect(await readWorkspaceMetadata(dir)).toEqual({ ok: false, reason: 'absent' }); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index 817aa2353..02811ea13 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -306,10 +306,14 @@ export function createWorkspaceRoutes( }); const resolveDefaultAgentId = async (meta: WorkspaceMeta): Promise => { - const metadata = await readWorkspaceMetadata(meta.dir); - if (metadata.ok && metadata.metadata.defaultAgent) { - const adapter = svc.adapters.get(metadata.metadata.defaultAgent); - if (adapter && isAgentRuntime(adapter)) return metadata.metadata.defaultAgent; + const settings = await readWorkspaceRuntimeSettings(meta.dir); + if (!settings.ok && settings.reason === 'invalid') { + throw new Error(`invalid Workspace runtime settings: ${settings.error}`); + } + const agent = settings.ok ? resolveWorkspaceRuntimeAgent(settings.settings, 'interactive') : undefined; + if (agent) { + const adapter = svc.adapters.get(agent); + if (adapter && isAgentRuntime(adapter)) return agent; } const configured = await readWorkspaceDefaultAgent().catch(() => null); if (configured) { @@ -1276,18 +1280,7 @@ export function createWorkspaceRoutes( else nextObj['description'] = v; } if ('defaultAgent' in fields) { - const v = fields['defaultAgent']; - if (v === null) { - delete nextObj['defaultAgent']; - } else if (typeof v === 'string') { - const adapter = svc.adapters.get(v); - if (!adapter || !isAgentRuntime(adapter)) { - return c.json({ error: 'invalid_agent', message: `unknown agent runtime: ${v}` }, 400); - } - nextObj['defaultAgent'] = v; - } else { - return c.json({ error: 'invalid_agent', message: 'defaultAgent must be a runtime id or null' }, 400); - } + return c.json({ error: 'invalid_metadata', message: 'Agent preferences belong in runtime-settings, not metadata' }, 400); } const next = workspaceMetadataSchema.safeParse(nextObj); if (!next.success) { diff --git a/src/workspaces/conversation-control.spec.ts b/src/workspaces/conversation-control.spec.ts index 3a6c89369..f48d6ac71 100644 --- a/src/workspaces/conversation-control.spec.ts +++ b/src/workspaces/conversation-control.spec.ts @@ -33,9 +33,10 @@ function fakeService(opts: { reconstruction?: ProvenanceRecord | null task?: HeadlessTaskRecord | null logsDir?: string + defaultAgent?: string workspaceTemplate?: string } = {}) { - const adapter = fakeAdapter() + const adapter = fakeAdapter(opts.defaultAgent) const workspace = { id: 'ws-peer', tag: 'peer-desk', @@ -59,6 +60,7 @@ function fakeService(opts: { : opts.provenance ?? null), append: appendProvenance, }, + resolveHeadlessDefaultAgentId: vi.fn(async () => opts.defaultAgent ?? 'pi'), resolveDefaultAgentId: vi.fn(async () => 'pi'), resolveOrCreateChatWorkspace: vi.fn(async () => ({ ok: true as const, workspace })), dispatchHeadlessTask, @@ -221,7 +223,7 @@ describe('Workspace conversation control', () => { }) it('creates a fresh Session only in the initialized default AutoQuant Workspace', async () => { - const { svc, workspace } = fakeService({ workspaceTemplate: 'auto-quant-v2' }) + const { svc, workspace, dispatchHeadlessTask } = fakeService({ workspaceTemplate: 'auto-quant-v2', defaultAgent: 'codex' }) const dependencies = { readQuickChatPreferences: vi.fn(async () => ({ recentChatWorkspaceId: null })), rememberRecentChatWorkspace: vi.fn(async () => undefined), @@ -236,6 +238,9 @@ describe('Workspace conversation control', () => { workspaceId: workspace.id, resolution: { mode: 'reconstructed', reason: 'harness-default' }, }) + expect(svc.resolveHeadlessDefaultAgentId).toHaveBeenCalledWith(workspace) + expect(svc.resolveDefaultAgentId).not.toHaveBeenCalled() + expect((dispatchHeadlessTask.mock.calls as unknown[][])[0]?.[1]).toMatchObject({ id: 'codex' }) }) it('does not create an AutoQuant Workspace when the Harness is not initialized', async () => { diff --git a/src/workspaces/conversation-control.ts b/src/workspaces/conversation-control.ts index 17a341242..ba4ac00a6 100644 --- a/src/workspaces/conversation-control.ts +++ b/src/workspaces/conversation-control.ts @@ -306,7 +306,7 @@ export function createWorkspaceConversationControl( } const agentId = continuingOrigin ? continuingOrigin.agent - : input.agent ?? await svc.resolveDefaultAgentId(meta) + : input.agent ?? await svc.resolveHeadlessDefaultAgentId(meta) if (!agentId) throw new Error(`workspace has no agent runtime: ${meta.tag}`) const adapter = svc.adapters.get(agentId) if (!adapter || !isAgentRuntime(adapter)) throw new Error(`unknown agent runtime: ${agentId}`) diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 5503992a4..cc2a40e46 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -456,6 +456,7 @@ export interface WorkspaceService { ): Promise; /** Resolve the Workspace default, installation fallback, then first registered runtime. */ resolveDefaultAgentId(meta: WorkspaceMeta): Promise; + resolveHeadlessDefaultAgentId(meta: WorkspaceMeta): Promise; resolveAdapter(meta: WorkspaceMeta, agentId?: string): CliAdapter; /** Open the same persisted Session through its runtime's structured protocol instead of a PTY. */ startWebSession( @@ -1104,21 +1105,24 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions /** Default for fresh interactive Sessions without an explicit runtime. */ const resolveDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => { - const metadata = await readWorkspaceMetadata(wsMeta.dir); - const workspaceDefault = metadata.ok - ? validRegisteredRuntime(metadata.metadata.defaultAgent ?? null) - : undefined; + const settings = await readWorkspaceRuntimeSettings(wsMeta.dir); + if (!settings.ok && settings.reason === 'invalid') { + throw new Error(`invalid Workspace runtime settings: ${settings.error}`); + } + const workspaceDefault = validRegisteredRuntime(settings.ok + ? resolveWorkspaceRuntimeAgent(settings.settings, 'interactive') ?? null + : null); return workspaceDefault ?? validRegisteredRuntime(await readWorkspaceDefaultAgent().catch(() => null)) ?? firstRegisteredRuntime(); }; /** - * Default for scheduled issues with no frontmatter `agent`: the Workspace's - * headless recent runtime first, then the legacy installation Issue default, + * Default for fresh headless work without an explicit Agent: the Workspace's + * headless fixed default, then recent runtime, then the installation Issue default, * its Session default, and finally the first registered runtime. */ - const resolveIssueDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => { + const resolveHeadlessDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => { const runtimeSettings = await readWorkspaceRuntimeSettings(wsMeta.dir); if (!runtimeSettings.ok && runtimeSettings.reason === 'invalid') { throw new Error(`invalid Workspace runtime settings: ${runtimeSettings.error}`); @@ -2294,7 +2298,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions return resolveAdapter(ws, identity.agent); } if (agentId) return resolveAdapter(ws, agentId); - return resolveAdapter(ws, await resolveIssueDefaultAgentId(ws)); + return resolveAdapter(ws, await resolveHeadlessDefaultAgentId(ws)); }, dispatch: dispatchHeadlessTaskMethod, claimFreshSession: async ({ issueWorkspace, issueId, taskId, resumeId, agent }) => { @@ -2430,7 +2434,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions Boolean(issue.when) && !issueAssigneeResumeId(issue.assignee) && !issue.agent, ); const defaultIssueAgent = needsDefaultAgent - ? await resolveIssueDefaultAgentId(ws) + ? await resolveHeadlessDefaultAgentId(ws) : undefined; const issues: IssuesSnapshotIssue[] = res.issues.filter((issue) => !isConnectorDeskIssue(issue)).map((issue) => { // Unscheduled ⇒ pure board work item, no firing markers. @@ -2491,7 +2495,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions if (ownerIdentity) await sessionRegistry.ensureLoaded(ownerIdentity.wsId); const assigneeSession = resolveIssueAssigneeSession(issue.assignee); const defaultIssueAgent = issue.when && !issueAssigneeResumeId(issue.assignee) && !issue.agent - ? await resolveIssueDefaultAgentId(ws) + ? await resolveHeadlessDefaultAgentId(ws) : undefined; const runtimeAvailability = issue.when ? issueRuntimeAvailability(issue, defaultIssueAgent, detectAgents()) @@ -3228,7 +3232,10 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions ...w, ...(metadata.ok ? metadata.metadata : {}), ...(!metadata.ok && metadata.reason === 'invalid' ? { metadataError: metadata.error } : {}), - ...(runtimeSettings.ok ? { runtimeSettings: runtimeSettings.settings } : {}), + ...(runtimeSettings.ok ? { + runtimeSettings: runtimeSettings.settings, + defaultAgent: resolveWorkspaceRuntimeAgent(runtimeSettings.settings, 'interactive'), + } : {}), ...(!runtimeSettings.ok && runtimeSettings.reason === 'invalid' ? { runtimeSettingsError: runtimeSettings.error } : {}), @@ -3356,6 +3363,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions resolveOrCreateAutoQuantWorkspace: resolveOrCreateAutoQuantWorkspaceMethod, resolveOrCreateAutoPredictionWorkspace: resolveOrCreateAutoPredictionWorkspaceMethod, resolveDefaultAgentId, + resolveHeadlessDefaultAgentId, resolveAdapter, startWebSession, refreshSessionTitles, diff --git a/src/workspaces/workspace-metadata.spec.ts b/src/workspaces/workspace-metadata.spec.ts index 9fbc9a8e1..e1b209a94 100644 --- a/src/workspaces/workspace-metadata.spec.ts +++ b/src/workspaces/workspace-metadata.spec.ts @@ -28,7 +28,6 @@ describe('workspace metadata', () => { await writeMetadata(JSON.stringify({ displayName: 'NVDA earnings thesis', description: 'Research the earnings setup.', - defaultAgent: 'pi', })) expect(await readWorkspaceMetadata(dir)).toEqual({ @@ -36,7 +35,6 @@ describe('workspace metadata', () => { metadata: { displayName: 'NVDA earnings thesis', description: 'Research the earnings setup.', - defaultAgent: 'pi', }, }) }) @@ -73,11 +71,11 @@ describe('workspace metadata', () => { }) it('writes canonical JSON through the same schema the reader uses', async () => { - await writeWorkspaceMetadata(dir, { displayName: ' AAPL review ', defaultAgent: ' codex ' }) + await writeWorkspaceMetadata(dir, { displayName: ' AAPL review ' }) expect(await readWorkspaceMetadata(dir)).toEqual({ ok: true, - metadata: { displayName: 'AAPL review', defaultAgent: 'codex' }, + metadata: { displayName: 'AAPL review' }, }) }) }) diff --git a/src/workspaces/workspace-metadata.ts b/src/workspaces/workspace-metadata.ts index 377e0e46d..cea6a50d0 100644 --- a/src/workspaces/workspace-metadata.ts +++ b/src/workspaces/workspace-metadata.ts @@ -19,12 +19,6 @@ const MAX_DESCRIPTION = 1000 export const workspaceMetadataSchema = z.object({ displayName: z.string().trim().min(1).max(MAX_DISPLAY_NAME).optional(), description: z.string().trim().min(1).max(MAX_DESCRIPTION).optional(), - /** - * Workspace-local default for a fresh Session. The routes validate that the - * id names a registered agent runtime before writing it; keeping the schema - * adapter-agnostic lets future runtimes participate without a data migration. - */ - defaultAgent: z.string().trim().min(1).max(64).optional(), }).strict() export type WorkspaceMetadata = z.infer diff --git a/ui/src/components/workspace/api.ts b/ui/src/components/workspace/api.ts index 5d1ddb767..0cc5770c9 100644 --- a/ui/src/components/workspace/api.ts +++ b/ui/src/components/workspace/api.ts @@ -44,7 +44,7 @@ export interface Workspace { readonly description?: string; /** Validation/read error for `.alice/workspace.json`, when present. */ readonly metadataError?: string; - /** Workspace-local runtime used for fresh Sessions when no launch overrides it. */ + /** Read-only projection of the interactive Agent preference in `.alice/settings.json`. */ readonly defaultAgent?: string; readonly dir: string; readonly createdAt: string; @@ -1591,7 +1591,6 @@ export async function purgeDepartedWorkspace(id: string): Promise { export type WorkspaceMetadataPatch = { displayName?: string | null; description?: string | null; - defaultAgent?: string | null; }; export async function updateWorkspaceMetadata( diff --git a/ui/src/contexts/WorkspacesContext.tsx b/ui/src/contexts/WorkspacesContext.tsx index fadbb0bdc..223c6948b 100644 --- a/ui/src/contexts/WorkspacesContext.tsx +++ b/ui/src/contexts/WorkspacesContext.tsx @@ -604,7 +604,7 @@ export function WorkspacesProvider({ children }: { children: ReactNode }) { const saveWorkspaceMetadata = useCallback( async ( wsId: string, - metadata: { displayName?: string | null; description?: string | null; defaultAgent?: string | null }, + metadata: { displayName?: string | null; description?: string | null }, ): Promise => { const updated = await updateWorkspaceMetadata(wsId, metadata) setWorkspaces((prev) => prev.map((w) => (w.id === wsId ? updated : w))) diff --git a/ui/src/contexts/workspaces-context.ts b/ui/src/contexts/workspaces-context.ts index c6f3291c2..57150da93 100644 --- a/ui/src/contexts/workspaces-context.ts +++ b/ui/src/contexts/workspaces-context.ts @@ -93,7 +93,7 @@ export interface WorkspacesContextValue { openAgentConfig(wsId: string, agent?: AgentId, section?: 'general' | 'launch' | 'ai' | 'template' | 'absorb'): void saveWorkspaceMetadata( wsId: string, - metadata: { displayName?: string | null; description?: string | null; defaultAgent?: string | null }, + metadata: { displayName?: string | null; description?: string | null }, ): Promise renameWorkspace(wsId: string, displayName: string): Promise } diff --git a/ui/src/demo/handlers/workspaces.ts b/ui/src/demo/handlers/workspaces.ts index 3270ca8e1..6aeb193ec 100644 --- a/ui/src/demo/handlers/workspaces.ts +++ b/ui/src/demo/handlers/workspaces.ts @@ -861,10 +861,12 @@ export const workspacesHandlers = [ const mutableWorkspace = workspace as { displayName?: string description?: string - defaultAgent?: string } const body = (await request.json().catch(() => ({}))) as WorkspaceMetadataPatch + if ('defaultAgent' in body) { + return HttpResponse.json({ error: 'invalid_metadata', message: 'Agent preferences belong in runtime-settings, not metadata' }, { status: 400 }) + } if ('displayName' in body) { if (body.displayName == null || body.displayName.trim() === '') { delete mutableWorkspace.displayName @@ -879,13 +881,6 @@ export const workspacesHandlers = [ mutableWorkspace.description = body.description.trim() } } - if ('defaultAgent' in body) { - if (body.defaultAgent == null || body.defaultAgent.trim() === '') { - delete mutableWorkspace.defaultAgent - } else { - mutableWorkspace.defaultAgent = body.defaultAgent.trim() - } - } return HttpResponse.json({ workspace }) }), http.put('/api/workspaces/:id/runtime-settings', async ({ params, request }) => { @@ -919,7 +914,7 @@ export const workspacesHandlers = [ version: 3 as const, runtime: { interactive: mode('interactive'), headless: mode('headless') }, } - const nextWorkspace = { ...workspace, runtimeSettings: nextSettings } + const nextWorkspace = { ...workspace, runtimeSettings: nextSettings, defaultAgent: nextSettings.runtime.interactive.defaultAgent ?? nextSettings.runtime.interactive.recent.agent } demoWorkspaces[index] = nextWorkspace return HttpResponse.json({ settings: nextSettings, workspace: nextWorkspace }) }),