Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/model-semantics-and-runtime-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
51 changes: 51 additions & 0 deletions src/migrations/0042_workspace_default_agent.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
72 changes: 72 additions & 0 deletions src/migrations/0042_workspace_default_agent/index.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown> | 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<string, unknown>
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
}

async function writeJson(path: string, value: unknown): Promise<void> {
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<void> {
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()),
}
1 change: 1 addition & 0 deletions src/migrations/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
4 changes: 2 additions & 2 deletions src/migrations/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
2 changes: 1 addition & 1 deletion src/webui/routes/inquiries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions src/webui/routes/workspaces-quickchat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' };
Expand Down
22 changes: 4 additions & 18 deletions src/webui/routes/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
25 changes: 9 additions & 16 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,14 @@ export function createWorkspaceRoutes(
});

const resolveDefaultAgentId = async (meta: WorkspaceMeta): Promise<string | undefined> => {
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) {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions src/workspaces/conversation-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/workspaces/conversation-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
Loading