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
33 changes: 32 additions & 1 deletion src/workspaces/workspace-creator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { EventEmitter } from 'node:events';
import * as childProcess from 'node:child_process';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { runScript } from './workspace-creator.js';
import { resolveCreateAgents, runScript } from './workspace-creator.js';

vi.mock('node:child_process', () => ({
spawn: vi.fn(),
Expand Down Expand Up @@ -42,6 +42,37 @@ function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value, configurable: true });
}

describe('resolveCreateAgents — single home of the agent policy', () => {
const ALL = ['claude', 'codex', 'opencode', 'pi'];

it('enables EVERY registered adapter when the caller pins nothing', () => {
// The quick-chat bug: it called create() with no explicit set, so it used
// to get only the template head (claude+codex). Policy now expands here.
expect(resolveCreateAgents(undefined, ['claude', 'codex'], ALL)).toEqual(ALL);
});

it('honors template defaultAgents only as the ORDER head (agents[0] default)', () => {
// A template that wants codex first still gets all four, codex leading.
expect(resolveCreateAgents(undefined, ['codex'], ALL)).toEqual([
'codex', 'claude', 'opencode', 'pi',
]);
});

it('first-wins dedupes when the head repeats a registered id', () => {
expect(resolveCreateAgents(undefined, ['pi', 'claude'], ALL)).toEqual([
'pi', 'claude', 'codex', 'opencode',
]);
});

it('an explicit non-empty request wins verbatim (subset pinning)', () => {
expect(resolveCreateAgents(['claude'], ['claude', 'codex'], ALL)).toEqual(['claude']);
});

it('treats an empty explicit request as "not pinned" → full expansion', () => {
expect(resolveCreateAgents([], ['claude', 'codex'], ALL)).toEqual(ALL);
});
});

describe('runScript platform branching', () => {
const originalPlatform = process.platform;

Expand Down
34 changes: 31 additions & 3 deletions src/workspaces/workspace-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ export type CreateResult =

const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/;

/**
* Resolve the adapter set a new workspace is created with. This is the single
* home of the agent policy, so every create path — the form, quick-chat,
* headless — converges on it:
*
* - An explicit `agentsRequested` (a caller pinning a subset) wins verbatim.
* - Otherwise a workspace gets EVERY registered adapter enabled; restricting
* it was a create-time decision with no first-action basis. The template's
* `defaultAgents` is honored only as the HEAD of the list (first-wins
* dedupe), so `agents[0]` — the "spawn a new session" default — follows
* template intent without limiting what's available.
*
* This used to live in the frontend create hook alone, which silently left
* backend-only callers (quick-chat) on the bare-`defaultAgents` set.
*/
export function resolveCreateAgents(
agentsRequested: readonly string[] | undefined,
templateDefaultAgents: readonly string[],
allAdapterIds: readonly string[],
): readonly string[] {
if (agentsRequested && agentsRequested.length > 0) return agentsRequested;
return [...new Set([...templateDefaultAgents, ...allAdapterIds])];
}

/**
* Creates a workspace by invoking the template's bootstrap script.
*
Expand Down Expand Up @@ -87,9 +111,13 @@ export class WorkspaceCreator {
};
}

const agents = agentsRequested && agentsRequested.length > 0
? agentsRequested
: template.defaultAgents;
// Agent policy lives in `resolveCreateAgents` (this file) so every create
// path — form, quick-chat, headless — converges on it.
const agents = resolveCreateAgents(
agentsRequested,
template.defaultAgents,
this.opts.adapterRegistry.list().map((a) => a.id),
);

// Validate every requested adapter exists in the registry.
for (const a of agents) {
Expand Down
1 change: 0 additions & 1 deletion ui/src/components/workspace/ChatWorkspaceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ export function ChatWorkspaceSection(): ReactElement | null {
{showCreate && (
<CreateWorkspaceDialog
templates={ctx.templates}
agents={ctx.agents}
presetTemplate={CHAT_TEMPLATE}
onCreated={(workspace) => {
ctx.refresh()
Expand Down
4 changes: 1 addition & 3 deletions ui/src/components/workspace/CreateWorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import { useTranslation } from 'react-i18next'

import { Dialog } from '../uta/Dialog'
import { CreateWorkspaceForm } from './CreateWorkspaceForm'
import type { AgentInfo, TemplateInfo, Workspace } from './api'
import type { TemplateInfo, Workspace } from './api'

export interface CreateWorkspaceDialogProps {
readonly templates: readonly TemplateInfo[]
readonly agents: readonly AgentInfo[]
/** Pin the template (Chat section). Omit for the general sidebar create. */
readonly presetTemplate?: string
/** Seed the tag input (e.g. the Chat section's date-based default). */
Expand All @@ -38,7 +37,6 @@ export function CreateWorkspaceDialog(props: CreateWorkspaceDialogProps): ReactE
<div className="px-5 py-4">
<CreateWorkspaceForm
templates={props.templates}
agents={props.agents}
presetTemplate={props.presetTemplate}
initialTag={props.initialTag}
autoFocusTag
Expand Down
8 changes: 2 additions & 6 deletions ui/src/components/workspace/CreateWorkspaceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ import { useTranslation } from 'react-i18next'

import { TAG_HINT, defaultTagFor, useCreateWorkspace } from '../../hooks/useCreateWorkspace'
import { useWorkspaces } from '../../contexts/WorkspacesContext'
import type { AgentInfo, TemplateInfo, Workspace } from './api'
import type { TemplateInfo, Workspace } from './api'

export interface CreateWorkspaceFormProps {
/** Full template catalog — drives the select and resolves defaultAgents. */
readonly templates: readonly TemplateInfo[]
/** All registered adapters; every workspace enables all of them. */
readonly agents: readonly AgentInfo[]
/**
* Pin the template (Chat section, template detail page). When set, no
* template select is shown. When omitted, the user picks from `templates`.
Expand All @@ -52,7 +50,7 @@ const HINT = 'text-[11px] text-text-muted/70'
export function CreateWorkspaceForm(props: CreateWorkspaceFormProps): ReactElement {
const { t } = useTranslation()
const { workspaces } = useWorkspaces()
const { templates, agents, presetTemplate, initialTag, onCancel, autoFocusTag } = props
const { templates, presetTemplate, initialTag, onCancel, autoFocusTag } = props

// Template selection. Fixed when `presetTemplate` is set; otherwise the
// user picks, defaulting to `chat` (then first available).
Expand All @@ -70,8 +68,6 @@ export function CreateWorkspaceForm(props: CreateWorkspaceFormProps): ReactEleme

const create = useCreateWorkspace({
template: effectiveTemplate,
templateDefaultAgents: selectedMeta?.defaultAgents,
availableAgents: agents,
onCreated: props.onCreated,
})

Expand Down
1 change: 0 additions & 1 deletion ui/src/components/workspace/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ export function Sidebar(props: SidebarProps): ReactElement {
{showCreate && (
<CreateWorkspaceDialog
templates={props.templates}
agents={props.agents}
onCreated={(workspace) => {
props.onChanged();
props.onSelectWorkspace(workspace.id);
Expand Down
9 changes: 7 additions & 2 deletions ui/src/components/workspace/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,20 @@ export async function listWorkspaces(): Promise<Workspace[]> {
return body.workspaces;
}

/**
* Create a workspace. `agents` is optional and normally omitted — the backend
* owns the "every registered adapter, template-headed" policy (see
* `WorkspaceCreator.create`). Pass an explicit set only to pin a subset.
*/
export async function createWorkspace(
tag: string,
template: string,
agents: readonly string[],
agents?: readonly string[],
): Promise<CreateResult> {
const res = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tag, template, agents }),
body: JSON.stringify(agents && agents.length > 0 ? { tag, template, agents } : { tag, template }),
});
if (res.ok) {
const body = (await res.json()) as { workspace: Workspace };
Expand Down
6 changes: 3 additions & 3 deletions ui/src/demo/handlers/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { http, HttpResponse } from 'msw'
import { demoWorkspaces, demoTemplates } from '../fixtures/workspaces'
import { demoChatWorkspace, demoWorkspaces, demoTemplates } from '../fixtures/workspaces'
import { demoWorkspaceFiles } from '../fixtures/inbox'

export const workspacesHandlers = [
Expand Down Expand Up @@ -64,13 +64,13 @@ export const workspacesHandlers = [
// Quick-chat launch — reuse the first demo chat workspace and hand back the
// scripted demo session (the Terminal short-circuits to DemoTerminalReplay).
http.post('/api/workspaces/quick-chat', () => {
const ws = demoWorkspaces[0]
const ws = demoChatWorkspace
return HttpResponse.json(
{
workspace: ws,
session: {
sessionId: 'demo-session',
wsId: ws?.id ?? 'demo-ws',
wsId: ws.id,
name: 'c1',
pid: 0,
startedAt: Date.now(),
Expand Down
36 changes: 8 additions & 28 deletions ui/src/hooks/useCreateWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'
import { createWorkspace, type AgentInfo, type Workspace } from '../components/workspace/api'
import { createWorkspace, type Workspace } from '../components/workspace/api'

export const TAG_HINT = 'a-z, 0-9, "-", "_", up to 33 chars'
export const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/
Expand All @@ -25,15 +25,6 @@ export function defaultTagFor(template: string, workspaces: readonly Workspace[]
interface UseCreateWorkspaceOpts {
/** Workspace template to create from. Empty string = not yet selected. */
template: string
/**
* Template's declared defaultAgents. Used to determine agents[0], which
* is the default adapter when the user spawns a new session via "+".
* The full set of adapters is always enabled regardless — this only
* sets the head of the list.
*/
templateDefaultAgents?: readonly string[]
/** All adapters registered with the workspace launcher. All get enabled. */
availableAgents: readonly AgentInfo[]
/** Called with the new workspace after a successful create. */
onCreated: (workspace: Workspace) => void
}
Expand All @@ -54,14 +45,12 @@ interface UseCreateWorkspaceState {
* agent-checkbox state + submit handler. They've drifted in small ways
* over time; bundling here keeps them in lockstep.
*
* Agent policy: every workspace gets every available adapter enabled.
* The CLI-checkbox row that previously asked users to pick was a
* decision with no first-action judgement basis; defaults were also
* wrong (only claude when a template didn't explicitly opt in to more).
* `templateDefaultAgents` is still honored as the head of the list so
* `agents[0]` — the "spawn a new session" default — follows template
* intent. Template authors can still steer the new-session default
* without restricting what's available.
* Agent policy lives in the backend (`WorkspaceCreator.create`): every
* workspace gets every registered adapter enabled, template-headed so
* `agents[0]` (the new-session default) follows template intent. This hook
* sends NO agent set, so the form, quick-chat, and headless all converge on
* that one source of truth — it used to expand the list here, which silently
* left backend-only callers (quick-chat) on the bare-defaultAgents set.
*/
export function useCreateWorkspace(opts: UseCreateWorkspaceOpts): UseCreateWorkspaceState {
const [tag, setTag] = useState('')
Expand All @@ -78,18 +67,9 @@ export function useCreateWorkspace(opts: UseCreateWorkspaceOpts): UseCreateWorks
setError('no template selected')
return
}
const head = opts.templateDefaultAgents ?? []
const seen = new Set<string>(head)
const agents: string[] = [...head]
for (const a of opts.availableAgents) {
if (!seen.has(a.id)) {
agents.push(a.id)
seen.add(a.id)
}
}
setCreating(true)
setError(null)
const result = await createWorkspace(t, opts.template, agents)
const result = await createWorkspace(t, opts.template)
setCreating(false)
if (result.ok) {
setTag('')
Expand Down
1 change: 0 additions & 1 deletion ui/src/pages/TemplateDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ export function TemplateDetailPage({ spec }: Props) {
{showCreate && (
<CreateWorkspaceDialog
templates={templates}
agents={agents}
presetTemplate={template.name}
onClose={() => setShowCreate(false)}
onCreated={(workspace) => {
Expand Down
Loading