From 3570477df03a91b2d0e087435d194293f880abce Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:31:59 +0800 Subject: [PATCH] feat(workspace): add headless conversation CLI --- default/skills/alice-workspace/SKILL.md | 27 ++++ docs/workspace-issues-and-scheduling.md | 14 ++ src/core/workspace-tool-center.ts | 37 +++++ src/main.ts | 2 + src/server/cli-commands.spec.ts | 2 + src/server/cli-commands.ts | 9 +- src/server/cli.spec.ts | 64 +++++++- src/server/cli.ts | 2 + src/server/mcp.ts | 2 + src/tool/conversation.spec.ts | 97 ++++++++++++ src/tool/conversation.ts | 115 ++++++++++++++ src/webui/routes/workspaces-quickchat.spec.ts | 11 ++ src/webui/routes/workspaces.spec.ts | 1 + src/webui/routes/workspaces.ts | 18 +-- src/workspaces/conversation-control.spec.ts | 145 ++++++++++++++++++ src/workspaces/conversation-control.ts | 98 ++++++++++++ src/workspaces/service.ts | 6 + 17 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 src/tool/conversation.spec.ts create mode 100644 src/tool/conversation.ts create mode 100644 src/workspaces/conversation-control.spec.ts create mode 100644 src/workspaces/conversation-control.ts diff --git a/default/skills/alice-workspace/SKILL.md b/default/skills/alice-workspace/SKILL.md index dd81ba37b..f2288813f 100644 --- a/default/skills/alice-workspace/SKILL.md +++ b/default/skills/alice-workspace/SKILL.md @@ -75,6 +75,33 @@ alice-workspace track search --query "uranium" alice-workspace track add --name uranium-ccj --description "Cameco — uranium miner" ``` +**Ask the agent who produced something** — continue the exact OpenAlice +conversation when its `resumeId` is available: + +```bash +alice-workspace conversation ask --resumeId --prompt "Why did you make this call?" +# -> { taskId, resumeId, workspace, agent, status: "running" } +alice-workspace conversation read --taskId +``` + +`ask` is asynchronous. Poll `read` until its status is no longer `running`; the +default response contains the latest assistant reply plus compact tool/error +activity. Add `--mode detailed` only when you need normalized tool inputs, +outputs, and all message blocks. + +If the originating conversation is unavailable but you know which desk owns the +work, start a fresh worker there instead: + +```bash +alice-workspace conversation ask --workspaceId --prompt "Reconstruct why this issue exists." +``` + +This goes through the embedded Workspace service and normal headless registry, +not the public HTTP API. A fresh target uses the user's configured default +runtime when that runtime is enabled there, otherwise the workspace's first +enabled runtime; `--agent ` overrides that choice. Never pass both +`--resumeId` and `--workspaceId`. + **The issue board** — the cross-workspace work list, shared by you and the user. It's *what's on the plate* when you've lost the thread — scan it when you start. **Reads are global, writes are local:** diff --git a/docs/workspace-issues-and-scheduling.md b/docs/workspace-issues-and-scheduling.md index 5f28d3ae7..fa23c784d 100644 --- a/docs/workspace-issues-and-scheduling.md +++ b/docs/workspace-issues-and-scheduling.md @@ -168,6 +168,20 @@ remain valid and are never renamed. `taskId` remains one execution, while `parentTaskId` records direct turn lineage. Runs and their logs are retained rather than silently pruned. +Internal agents use the same identity through the embedded Workspace CLI: + +```bash +alice-workspace conversation ask --resumeId --prompt "Why?" +alice-workspace conversation read --taskId +``` + +The ask path calls `WorkspaceService.dispatchHeadlessTask` directly; it does not +loop back through the authenticated public `/api` surface or expose the native +runtime session id. If no origin conversation exists, `--workspaceId` starts a +fresh headless conversation at the owning workspace. Both paths create ordinary +durable run records, obey global capacity and per-resume concurrency guards, and +return a `taskId` whose normalized reply/tool blocks are polled with `read`. + Scheduling never bypasses trading approval. A headless agent may research or stage a trade, but execution remains behind UTA/Trading-as-Git permission and human approval boundaries. diff --git a/src/core/workspace-tool-center.ts b/src/core/workspace-tool-center.ts index 28f833964..f8ee2ce70 100644 --- a/src/core/workspace-tool-center.ts +++ b/src/core/workspace-tool-center.ts @@ -31,6 +31,41 @@ import type { IEntityStore } from './entity-store.js' // core/ free of any runtime dependency on the workspaces/ module (no // core→workspaces coupling), while letting the board reader below be typed. import type { IssuesSnapshot, IssueDetail, WikilinkIssueRef } from '../workspaces/issues/board.js' +import type { HeadlessStructuredOutput } from '../workspaces/headless-output.js' +import type { HeadlessTaskStatus } from '../workspaces/headless-task-registry.js' + +export interface WorkspaceConversationTask { + readonly taskId: string + readonly resumeId: string + readonly parentTaskId?: string + readonly workspaceId: string + readonly issueId?: string + readonly agent: string + readonly status: HeadlessTaskStatus + readonly startedAt: number + readonly finishedAt?: number + readonly durationMs?: number + readonly error?: string + readonly structured: HeadlessStructuredOutput | null +} + +export interface WorkspaceConversationControl { + ask(input: { + readonly prompt: string + readonly timeoutMs: number + readonly resumeId?: string + readonly workspaceId?: string + readonly agent?: string + }): Promise<{ + readonly taskId: string + readonly resumeId: string + readonly workspaceId: string + readonly workspace: string + readonly agent: string + readonly continued: boolean + }> + read(taskId: string): Promise +} // ==================== Context handed to factories ==================== @@ -56,6 +91,8 @@ export interface WorkspaceToolContext { * it needs the live WorkspaceService (created after this center); the two * build sites (cli.ts, mcp.ts) inject a lazy closure, tests may omit it. */ resolveWorkspace?: (id: string) => { id: string; dir: string; tag: string } | null + /** Embedded headless conversation control; never routes through public HTTP. */ + conversation?: WorkspaceConversationControl /** Agent-INVISIBLE run provenance, resolved server-side from the * `x-openalice-run` header by the MCP / CLI route (never supplied by the * agent). Factories pass it through to call sites (e.g. inbox_push → diff --git a/src/main.ts b/src/main.ts index 0530baa0a..73e9c51b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,6 +60,7 @@ import { createEntityStore } from './core/entity-store.js' import { entityUpsertFactory } from './tool/entity-upsert.js' import { entitySearchFactory } from './tool/entity-search.js' import { issueToolFactories } from './tool/issue-tools.js' +import { conversationToolFactories } from './tool/conversation.js' import { createToolCallLog } from './core/tool-call-log.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' @@ -112,6 +113,7 @@ async function main() { workspaceToolCenter.register(entityUpsertFactory) workspaceToolCenter.register(entitySearchFactory) for (const f of issueToolFactories) workspaceToolCenter.register(f) + for (const f of conversationToolFactories) workspaceToolCenter.register(f) // ==================== UTA SDK (HTTP boundary) ==================== // diff --git a/src/server/cli-commands.spec.ts b/src/server/cli-commands.spec.ts index eaca33f52..57f7d8bbb 100644 --- a/src/server/cli-commands.spec.ts +++ b/src/server/cli-commands.spec.ts @@ -22,6 +22,7 @@ import { workspacePathFactory } from '../tool/workspace-path.js' import { entityUpsertFactory } from '../tool/entity-upsert.js' import { entitySearchFactory } from '../tool/entity-search.js' import { issueToolFactories } from '../tool/issue-tools.js' +import { conversationToolFactories } from '../tool/conversation.js' import { createTradingTools } from '../tool/trading.js' /** @@ -87,6 +88,7 @@ describe('CLI_EXPORTS — workspace export (scoped collaboration tools)', () => wtc.register(entityUpsertFactory) wtc.register(entitySearchFactory) for (const f of issueToolFactories) wtc.register(f) + for (const f of conversationToolFactories) wtc.register(f) const built = wtc.build({ workspaceId: 'ws-test', workspaceLabel: 'test', diff --git a/src/server/cli-commands.ts b/src/server/cli-commands.ts index 0cab50282..622069e66 100644 --- a/src/server/cli-commands.ts +++ b/src/server/cli-commands.ts @@ -145,7 +145,7 @@ export const CLI_EXPORTS: Record = { workspace: { binary: 'alice-workspace', scope: 'scoped', - description: 'Agent collaboration — push/read the user inbox, locate a peer workspace (peer path), track entities', + description: 'Agent collaboration — Inbox, peer workspaces, issues, tracked entities, and headless conversation follow-ups', commands: { // inbox push: surface doc(s) + comment to the user's Inbox tab. Attach // files with repeatable `--doc ` (the shim folds them into the @@ -181,6 +181,13 @@ export const CLI_EXPORTS: Record = { list: 'issue_list', show: 'issue_show', }, + // conversation: ask the exact originating worker by product resumeId, + // or start a fresh worker at a peer workspace when provenance is absent. + // Results stay in the normal headless registry and are read by taskId. + conversation: { + ask: 'conversation_ask', + read: 'conversation_read', + }, }, }, uta: { diff --git a/src/server/cli.spec.ts b/src/server/cli.spec.ts index 262aa5119..6c8526994 100644 --- a/src/server/cli.spec.ts +++ b/src/server/cli.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { Hono } from 'hono' import { ToolCenter } from '../core/tool-center.js' import { WorkspaceToolCenter } from '../core/workspace-tool-center.js' @@ -8,6 +8,7 @@ import { workspacePathFactory } from '../tool/workspace-path.js' import { createMemoryInboxStore } from '../core/inbox-store.js' import { extractMcpShape } from '../core/mcp-export.js' import { inboxPushFactory } from '../tool/inbox-push.js' +import { conversationAskFactory } from '../tool/conversation.js' import { registerCliRoutes, type CliGatewayDeps } from './cli.js' /** @@ -184,6 +185,67 @@ describe('CLI gateway — inbox read (scoped, string-arg coercion)', () => { }) }) +describe('CLI gateway — embedded conversation dispatch', () => { + it('coerces timeoutMs and dispatches by resumeId without public HTTP', async () => { + const adapter = { + id: 'pi', + kind: 'agent', + capabilities: { headless: true }, + composeHeadlessCommand: () => ['pi'], + } + const target = { + id: 'ws-peer', tag: 'peer', dir: '/tmp/peer', createdAt: '2026-07-11T00:00:00.000Z', agents: ['pi'], + } + const dispatchHeadlessTask = vi.fn(async () => ({ taskId: 'task-1', resumeId: 'resume-1' })) + const fakeSvc = { + registry: { + get: (id: string) => id === 'ws1' + ? { id: 'ws1', tag: 'caller' } + : id === target.id ? target : undefined, + }, + resumeRegistry: { + get: (id: string) => id === 'resume-1' + ? { resumeId: id, wsId: target.id, agent: 'pi' } + : null, + }, + adapters: { get: (id: string) => id === 'pi' ? adapter : undefined }, + config: { launcherRepoRoot: '/repo' }, + dispatchHeadlessTask, + } + const wtc = new WorkspaceToolCenter() + wtc.register(conversationAskFactory) + const app = new Hono() + registerCliRoutes(app, { + toolCenter: new ToolCenter(), + workspaceToolCenter: wtc, + inboxStore: {} as never, + entityStore: {} as never, + getWorkspaceService: () => fakeSvc as never, + }) + + const res = await app.request('/cli/ws1/workspace/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: 'conversation_ask', + args: { resumeId: 'resume-1', prompt: 'why?', timeoutMs: '1234' }, + }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { content: Array<{ text?: string }> } + const payload = JSON.parse(body.content.map((block) => block.text ?? '').join('')) + expect(payload).toMatchObject({ ok: true, taskId: 'task-1', continued: true }) + expect(dispatchHeadlessTask).toHaveBeenCalledWith( + target, + adapter, + 'why?', + 1234, + undefined, + 'resume-1', + ) + }) +}) + describe('CLI gateway — agent-invisible origin (x-openalice-run → registry)', () => { // The `alice` shim forwards the spawn-injected AQ_RUN_ID as an `x-openalice-run` // header on /invoke. The gateway resolves it through the headless registry diff --git a/src/server/cli.ts b/src/server/cli.ts index 3d97666e1..c8f79c45a 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -31,6 +31,7 @@ import { type WorkspaceToolCenter, makeWorkspaceResolver } from '../core/workspa import type { IInboxStore, InboxOrigin } from '../core/inbox-store.js' import type { IEntityStore } from '../core/entity-store.js' import type { WorkspaceService } from '../workspaces/service.js' +import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js' import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js' import { type CliExport, getExport, mappedToolNames } from './cli-commands.js' import { resolveInboxOrigin } from './inbox-origin.js' @@ -87,6 +88,7 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void { resolveWorkspace: makeWorkspaceResolver(getWorkspaceService), ...(svc ? { + conversation: createWorkspaceConversationControl(svc), board: { snapshot: () => svc.issuesSnapshot(), detail: (w: string, i: string) => svc.issueDetail(w, i), diff --git a/src/server/mcp.ts b/src/server/mcp.ts index b30b18bfe..13c3a6e33 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -9,6 +9,7 @@ import { type WorkspaceToolCenter, makeWorkspaceResolver } from '../core/workspa import type { IInboxStore } from '../core/inbox-store.js' import type { IEntityStore } from '../core/entity-store.js' import type { WorkspaceService } from '../workspaces/service.js' +import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js' import type { InboxOrigin } from '../core/inbox-store.js' import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js' import { registerCliRoutes } from './cli.js' @@ -103,6 +104,7 @@ export class McpPlugin implements Plugin { resolveWorkspace: makeWorkspaceResolver(getWorkspaceService), ...(svc ? { + conversation: createWorkspaceConversationControl(svc), board: { snapshot: () => svc.issuesSnapshot(), detail: (w: string, i: string) => svc.issueDetail(w, i), diff --git a/src/tool/conversation.spec.ts b/src/tool/conversation.spec.ts new file mode 100644 index 000000000..6d545ae08 --- /dev/null +++ b/src/tool/conversation.spec.ts @@ -0,0 +1,97 @@ +import type { Tool } from 'ai' +import { describe, expect, it, vi } from 'vitest' + +import type { WorkspaceToolContext } from '../core/workspace-tool-center.js' +import { conversationAskFactory, conversationReadFactory } from './conversation.js' + +async function run(tool: Tool, args: Record) { + return tool.execute!(args, { toolCallId: 'test', messages: [] }) +} + +function context(over: Partial = {}): WorkspaceToolContext { + return { + workspaceId: 'ws-caller', + workspaceLabel: 'caller', + inboxStore: {} as never, + entityStore: {} as never, + ...over, + } +} + +describe('conversation_ask', () => { + it('requires exactly one target', async () => { + const tool = conversationAskFactory.build(context({ + conversation: { ask: vi.fn(), read: vi.fn() }, + })) + + await expect(run(tool, { prompt: 'why?' })).resolves.toMatchObject({ ok: false }) + await expect(run(tool, { + prompt: 'why?', resumeId: 'resume-1', workspaceId: 'ws-1', + })).resolves.toMatchObject({ ok: false }) + }) + + it('dispatches a resumed follow-up and returns its task handle', async () => { + const ask = vi.fn(async () => ({ + taskId: 'task-1', + resumeId: 'resume-1', + workspaceId: 'ws-peer', + workspace: 'peer', + agent: 'pi', + continued: true, + })) + const tool = conversationAskFactory.build(context({ + conversation: { ask, read: vi.fn() }, + })) + + await expect(run(tool, { + prompt: 'why?', resumeId: 'resume-1', + })).resolves.toMatchObject({ ok: true, status: 'running', taskId: 'task-1' }) + expect(ask).toHaveBeenCalledWith({ + prompt: 'why?', resumeId: 'resume-1', timeoutMs: 300_000, + }) + }) +}) + +describe('conversation_read', () => { + const task = { + taskId: 'task-1', + resumeId: 'resume-1', + workspaceId: 'ws-peer', + agent: 'pi', + status: 'done' as const, + startedAt: 1, + durationMs: 2, + structured: { + schemaVersion: 1 as const, + assistantText: 'The report followed the issue rule.', + blocks: [ + { type: 'tool' as const, id: 'tool-1', name: 'Read', status: 'completed' as const, input: 'a.md', output: 'ok' }, + { type: 'text' as const, text: 'The report followed the issue rule.' }, + ], + metrics: { textBlocks: 1, toolCalls: 1, toolFailures: 0 }, + truncated: false, + }, + } + + it('keeps default output decision-oriented', async () => { + const tool = conversationReadFactory.build(context({ + conversation: { ask: vi.fn(), read: vi.fn(async () => task) }, + })) + const result = await run(tool, { taskId: task.taskId }) + + expect(result).toMatchObject({ + ok: true, + assistantText: 'The report followed the issue rule.', + tools: [{ name: 'Read', status: 'completed' }], + }) + expect(result).not.toHaveProperty('blocks') + }) + + it('returns normalized blocks only in detailed mode', async () => { + const tool = conversationReadFactory.build(context({ + conversation: { ask: vi.fn(), read: vi.fn(async () => task) }, + })) + await expect(run(tool, { taskId: task.taskId, mode: 'detailed' })) + .resolves.toMatchObject({ blocks: task.structured.blocks }) + }) +}) diff --git a/src/tool/conversation.ts b/src/tool/conversation.ts new file mode 100644 index 000000000..e4b666393 --- /dev/null +++ b/src/tool/conversation.ts @@ -0,0 +1,115 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { WorkspaceToolFactory } from '../core/workspace-tool-center.js' +import type { HeadlessMessageBlock } from '../workspaces/headless-output.js' + +const DEFAULT_TIMEOUT_MS = 300_000 +const MAX_TIMEOUT_MS = 1_800_000 +const MAX_PROMPT_CHARS = 16_000 + +export const conversationAskFactory: WorkspaceToolFactory = { + name: 'conversation_ask', + build(ctx) { + return tool({ + description: [ + 'Ask an agent in a Workspace through OpenAlice headless dispatch.', + '', + 'Pass exactly one target: `resumeId` continues the specific agent conversation exposed by Inbox/Issue provenance; `workspaceId` starts a fresh conversation with that workspace\'s first enabled runtime (or the explicit `agent`).', + '', + 'The call is asynchronous and returns `taskId`. Poll it with `conversation_read` (CLI: `alice-workspace conversation read --taskId …`). This embedded Workspace capability does not call the public HTTP API.', + ].join('\n'), + inputSchema: z.object({ + prompt: z.string().trim().min(1).max(MAX_PROMPT_CHARS) + .describe('The question or follow-up for the target agent.'), + resumeId: z.string().min(1).optional() + .describe('Continue one specific OpenAlice conversation. Mutually exclusive with workspaceId.'), + workspaceId: z.string().min(1).optional() + .describe('Start a fresh worker in this workspace when no originating conversation is available.'), + agent: z.string().min(1).optional() + .describe('Runtime for a fresh workspace target. Not allowed with resumeId.'), + timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional() + .describe(`Headless watchdog in milliseconds (default ${DEFAULT_TIMEOUT_MS}).`), + }), + execute: async ({ prompt, resumeId, workspaceId, agent, timeoutMs }) => { + if (!ctx.conversation) { + return { ok: false as const, error: 'workspace conversation control is unavailable' } + } + if (Boolean(resumeId) === Boolean(workspaceId)) { + return { ok: false as const, error: 'pass exactly one of resumeId or workspaceId' } + } + if (resumeId && agent) { + return { ok: false as const, error: 'agent cannot be used with resumeId' } + } + try { + const dispatched = await ctx.conversation.ask({ + prompt, + timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(resumeId ? { resumeId } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(agent ? { agent } : {}), + }) + return { ok: true as const, status: 'running' as const, ...dispatched } + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) } + } + }, + }) + }, +} + +export const conversationReadFactory: WorkspaceToolFactory = { + name: 'conversation_read', + build(ctx) { + return tool({ + description: [ + 'Read one headless conversation turn started by `conversation_ask`.', + '', + 'Default `summary` returns the latest assistant reply plus compact tool/error activity. Use `detailed` only when tool inputs/outputs or the full normalized block timeline are needed. A running task may have partial or no structured output yet; poll again.', + ].join('\n'), + inputSchema: z.object({ + taskId: z.string().min(1).describe('The taskId returned by conversation_ask.'), + mode: z.enum(['summary', 'detailed']).optional() + .describe('summary (default) or detailed normalized message blocks.'), + }), + execute: async ({ taskId, mode }) => { + if (!ctx.conversation) { + return { ok: false as const, error: 'workspace conversation control is unavailable' } + } + try { + const task = await ctx.conversation.read(taskId) + if (!task) return { ok: false as const, error: `conversation task not found: ${taskId}` } + const structured = task.structured + const tools = structured?.blocks + .filter((block): block is Extract => block.type === 'tool') + .map((block) => ({ name: block.name, status: block.status })) ?? [] + const errors = structured?.blocks + .filter((block): block is Extract => block.type === 'error') + .map((block) => block.message) ?? [] + return { + ok: true as const, + taskId: task.taskId, + resumeId: task.resumeId, + workspaceId: task.workspaceId, + agent: task.agent, + status: task.status, + assistantText: structured?.assistantText ?? null, + tools, + errors, + ...(task.parentTaskId ? { parentTaskId: task.parentTaskId } : {}), + ...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}), + ...(task.error ? { error: task.error } : {}), + ...(mode === 'detailed' ? { blocks: structured?.blocks ?? [] } : {}), + } + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) } + } + }, + }) + }, +} + +export const conversationToolFactories: WorkspaceToolFactory[] = [ + conversationAskFactory, + conversationReadFactory, +] diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index 400f99621..d8529c5fe 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -98,6 +98,17 @@ function build(opts: { resolveOrCreateChatWorkspace: (preferredWorkspaceId?: string | null) => chatWorkspaceResolver.resolveOrCreate(preferredWorkspaceId), resolveAdapter: (_m: any, agentId?: string) => adapters[agentId ?? 'claude'] ?? claude, + resolveDefaultAgentId: vi.fn(async (meta: any) => { + const configured = await readWorkspaceDefaultAgent(); + if (configured && meta.agents.includes(configured)) { + const adapter = adapters[configured]; + if (adapter && adapter.kind !== 'utility') return configured; + } + return meta.agents.find((id: string) => { + const adapter = adapters[id]; + return adapter && adapter.kind !== 'utility'; + }); + }), adapters: { get: (id: string) => adapters[id] }, sessionRegistry, resumeRegistry, diff --git a/src/webui/routes/workspaces.spec.ts b/src/webui/routes/workspaces.spec.ts index 0d127964f..25255e6d5 100644 --- a/src/webui/routes/workspaces.spec.ts +++ b/src/webui/routes/workspaces.spec.ts @@ -81,6 +81,7 @@ function build( const svc = { registry: { get: (id: string) => (id === 'ws-1' ? meta : undefined) }, adapters: { get: (a: string) => adapters[a] }, + resolveDefaultAgentId: vi.fn(async (m: any) => m.agents[0]), resolveAdapter: (_m: any, a?: string) => opts.resolveTo ?? adapters[a ?? 'claude'] ?? claude, config: { launcherRepoRoot: '/repo' }, runHeadlessTask, diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index 5ff9c61ee..c36205032 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -29,7 +29,7 @@ import type { WorkspaceMeta } from '../../workspaces/workspace-registry.js'; import { HeadlessCapacityError, HeadlessResumeError, resumeFromRecord, type SessionFactoryContext, type WorkspaceService } from '../../workspaces/service.js'; import { isAgentRuntime, type CliAdapter, type WorkspaceAiCred } from '../../workspaces/cli-adapter.js'; import { generatePetnameId } from '../../workspaces/petname-id.js'; -import { addCredential, readCredentials, readWorkspaceDefaultAgent, setCredentialLastModel, credentialWires, credentialWireShapeEnum, type Credential } from '../../core/config.js'; +import { addCredential, readCredentials, setCredentialLastModel, credentialWires, credentialWireShapeEnum, type Credential } from '../../core/config.js'; import { inferCredentialVendor, resolveAnthropicAuthMode } from '../../core/credential-inference.js'; import { compatibleCredentials, matchCredentialByApiKey } from '../../workspaces/credential-injection.js'; import { @@ -147,18 +147,6 @@ export function createWorkspaceRoutes( const app = new Hono(); const headlessSessionInFlight = new Map>(); - const resolveDefaultAgentId = async (meta: WorkspaceMeta): Promise => { - const configured = await readWorkspaceDefaultAgent().catch(() => null); - if (configured && meta.agents.includes(configured)) { - const adapter = svc.adapters.get(configured); - if (adapter && isAgentRuntime(adapter)) return configured; - } - return meta.agents.find((id) => { - const adapter = svc.adapters.get(id); - return adapter ? isAgentRuntime(adapter) : false; - }); - }; - /** * Spawn one interactive PTY session in an existing workspace — the shared * core of `POST /:id/sessions/spawn` and `POST /quick-chat` (so the two never @@ -198,7 +186,7 @@ export function createWorkspaceRoutes( return { ok: false, status: 409, body: { error: 'resume_busy', message: 'this conversation already has a running turn' } }; } if (requestedIdentity?.agentSessionId) resume = { sessionId: requestedIdentity.agentSessionId }; - const agentId = opts.agentId ?? requestedIdentity?.agent ?? await resolveDefaultAgentId(meta); + const agentId = opts.agentId ?? requestedIdentity?.agent ?? await svc.resolveDefaultAgentId(meta); if (!agentId) { return { ok: false, status: 400, body: { error: 'no_agent_runtime', message: 'workspace has no agent runtime enabled' } }; } @@ -1399,7 +1387,7 @@ export function createWorkspaceRoutes( if (agentId && !meta.agents.includes(agentId)) { return c.json({ error: 'agent_not_enabled', message: `agent "${agentId}" not enabled on this workspace` }, 400); } - const effectiveAgentId = agentId ?? resumeIdentity?.agent ?? await resolveDefaultAgentId(meta); + const effectiveAgentId = agentId ?? resumeIdentity?.agent ?? await svc.resolveDefaultAgentId(meta); if (!effectiveAgentId) { return c.json({ error: 'no_agent_runtime', message: 'workspace has no agent runtime enabled' }, 400); } diff --git a/src/workspaces/conversation-control.spec.ts b/src/workspaces/conversation-control.spec.ts new file mode 100644 index 000000000..b83e30b31 --- /dev/null +++ b/src/workspaces/conversation-control.spec.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { CliAdapter } from './cli-adapter.js' +import { createWorkspaceConversationControl } from './conversation-control.js' +import { headlessLogPaths, type HeadlessTaskRecord } from './headless-task-registry.js' +import type { WorkspaceService } from './service.js' + +const dirs: string[] = [] +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +function fakeAdapter(id = 'pi'): CliAdapter { + return { + id, + kind: 'agent', + capabilities: { headless: true }, + composeHeadlessCommand: () => [id], + bootstrap: vi.fn(async () => undefined), + } as unknown as CliAdapter +} + +function fakeService(opts: { + identity?: { resumeId: string; wsId: string; agent: string } | null + task?: HeadlessTaskRecord | null + logsDir?: string +} = {}) { + const adapter = fakeAdapter() + const workspace = { + id: 'ws-peer', + tag: 'peer-desk', + dir: '/tmp/peer-desk', + createdAt: '2026-07-11T00:00:00.000Z', + agents: ['pi'], + } + const dispatchHeadlessTask = vi.fn(async () => ({ + taskId: 'task-follow-up', + resumeId: opts.identity?.resumeId ?? 'resume-fresh', + })) + const svc = { + config: { launcherRepoRoot: '/repo' }, + registry: { get: (id: string) => id === workspace.id ? workspace : undefined }, + adapters: { get: (id: string) => id === adapter.id ? adapter : undefined }, + resumeRegistry: { get: () => opts.identity ?? null }, + resolveDefaultAgentId: vi.fn(async () => 'pi'), + dispatchHeadlessTask, + headlessTasks: { get: () => opts.task ?? null }, + headlessLogsDir: opts.logsDir ?? '/tmp/logs', + } as unknown as WorkspaceService + return { svc, adapter, workspace, dispatchHeadlessTask } +} + +describe('Workspace conversation control', () => { + it('continues the exact runtime conversation behind a resumeId', async () => { + const identity = { resumeId: 'resume-peer', wsId: 'ws-peer', agent: 'pi' } + const { svc, adapter, workspace, dispatchHeadlessTask } = fakeService({ identity }) + const control = createWorkspaceConversationControl(svc) + + await expect(control.ask({ + resumeId: identity.resumeId, + prompt: 'Why did you make this call?', + timeoutMs: 300_000, + })).resolves.toEqual({ + taskId: 'task-follow-up', + resumeId: identity.resumeId, + workspaceId: workspace.id, + workspace: workspace.tag, + agent: 'pi', + continued: true, + }) + expect(dispatchHeadlessTask).toHaveBeenCalledWith( + workspace, + adapter, + 'Why did you make this call?', + 300_000, + undefined, + identity.resumeId, + ) + }) + + it('starts a fresh worker at the target workspace when no origin exists', async () => { + const { svc, adapter, workspace, dispatchHeadlessTask } = fakeService() + const control = createWorkspaceConversationControl(svc) + + await expect(control.ask({ + workspaceId: workspace.id, + prompt: 'Reconstruct the rationale from this workspace.', + timeoutMs: 300_000, + })).resolves.toMatchObject({ + resumeId: 'resume-fresh', + workspace: workspace.tag, + agent: 'pi', + continued: false, + }) + expect(dispatchHeadlessTask).toHaveBeenCalledWith( + workspace, + adapter, + 'Reconstruct the rationale from this workspace.', + 300_000, + undefined, + undefined, + ) + }) + + it('reads normalized output without exposing the native agent session id', async () => { + const logsDir = await mkdtemp(join(tmpdir(), 'conversation-control-')) + dirs.push(logsDir) + const task: HeadlessTaskRecord = { + taskId: 'task-1', + resumeId: 'resume-1', + parentTaskId: 'task-0', + wsId: 'ws-peer', + agent: 'pi', + prompt: 'why?', + status: 'done', + startedAt: 1, + finishedAt: 2, + durationMs: 1, + agentSessionId: 'native-secret', + } + const structured = { + schemaVersion: 1 as const, + assistantText: 'Because the breadth rule passed.', + blocks: [{ type: 'text' as const, text: 'Because the breadth rule passed.' }], + metrics: { textBlocks: 1, toolCalls: 0, toolFailures: 0 }, + truncated: false, + } + await writeFile(headlessLogPaths(logsDir, task.taskId).structured, JSON.stringify(structured)) + const { svc } = fakeService({ task, logsDir }) + + const result = await createWorkspaceConversationControl(svc).read(task.taskId) + expect(result).toMatchObject({ + taskId: task.taskId, + resumeId: task.resumeId, + parentTaskId: task.parentTaskId, + status: 'done', + structured, + }) + expect(result).not.toHaveProperty('agentSessionId') + }) +}) diff --git a/src/workspaces/conversation-control.ts b/src/workspaces/conversation-control.ts new file mode 100644 index 000000000..c5fedd8aa --- /dev/null +++ b/src/workspaces/conversation-control.ts @@ -0,0 +1,98 @@ +import { readFile } from 'node:fs/promises' + +import type { + WorkspaceConversationControl, + WorkspaceConversationTask, +} from '../core/workspace-tool-center.js' +import { isAgentRuntime } from './cli-adapter.js' +import type { HeadlessStructuredOutput } from './headless-output.js' +import { headlessLogPaths } from './headless-task-registry.js' +import type { WorkspaceService } from './service.js' + +export function createWorkspaceConversationControl( + svc: WorkspaceService, +): WorkspaceConversationControl { + return { + async ask(input) { + const identity = input.resumeId ? svc.resumeRegistry.get(input.resumeId) : null + if (input.resumeId && !identity) { + throw new Error(`resume conversation not found: ${input.resumeId}`) + } + + const wsId = identity?.wsId ?? input.workspaceId + if (!wsId) throw new Error('resumeId or workspaceId is required') + const meta = svc.registry.get(wsId) + if (!meta) throw new Error(`workspace not found: ${wsId}`) + + if (identity && input.agent) { + throw new Error('agent cannot override the runtime of an existing conversation') + } + const agentId = identity?.agent ?? input.agent ?? await svc.resolveDefaultAgentId(meta) + if (!agentId) throw new Error(`workspace has no agent runtime: ${meta.tag}`) + if (!identity && !meta.agents.includes(agentId)) { + throw new Error(`agent "${agentId}" is not enabled on workspace ${meta.tag}`) + } + const adapter = svc.adapters.get(agentId) + if (!adapter || !isAgentRuntime(adapter)) { + throw new Error(`unknown agent runtime: ${agentId}`) + } + if (!adapter.capabilities.headless || !adapter.composeHeadlessCommand) { + throw new Error(`agent runtime has no headless mode: ${agentId}`) + } + + await adapter.bootstrap?.({ + wsId: meta.id, + cwd: meta.dir, + launcherRepoRoot: svc.config.launcherRepoRoot, + }) + const dispatched = await svc.dispatchHeadlessTask( + meta, + adapter, + input.prompt, + input.timeoutMs, + undefined, + identity?.resumeId, + ) + return { + ...dispatched, + workspaceId: meta.id, + workspace: meta.tag, + agent: adapter.id, + continued: identity !== null, + } + }, + + async read(taskId) { + const task = svc.headlessTasks.get(taskId) + if (!task) return null + const structured = await readStructuredSnapshot( + headlessLogPaths(svc.headlessLogsDir, taskId).structured, + ) + const result: WorkspaceConversationTask = { + taskId: task.taskId, + resumeId: task.resumeId, + workspaceId: task.wsId, + agent: task.agent, + status: task.status, + startedAt: task.startedAt, + structured, + ...(task.parentTaskId ? { parentTaskId: task.parentTaskId } : {}), + ...(task.issueId ? { issueId: task.issueId } : {}), + ...(task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {}), + ...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}), + ...(task.error ? { error: task.error } : {}), + } + return result + }, + } +} + +async function readStructuredSnapshot(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as HeadlessStructuredOutput + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.blocks)) return null + return parsed + } catch { + return null + } +} diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 9b309ba58..aaeaabb8e 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -146,6 +146,8 @@ export interface WorkspaceService { readonly transcriptWatcher: TranscriptWatcher; /** Resolve the preferred/recent durable Chat Workspace, creating one starter when absent. */ resolveOrCreateChatWorkspace(preferredWorkspaceId?: string | null): Promise; + /** Resolve the user's configured runtime when enabled here, else the first enabled runtime. */ + resolveDefaultAgentId(meta: WorkspaceMeta): Promise; resolveAdapter(meta: WorkspaceMeta, agentId?: string): CliAdapter; publicMeta(w: WorkspaceMeta): Promise; /** @@ -429,6 +431,9 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions */ const resolveIssueDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => validRuntimeForWorkspace(wsMeta, await readIssueDefaultAgent().catch(() => null)) ?? + await resolveWorkspaceDefaultAgentId(wsMeta); + + const resolveWorkspaceDefaultAgentId = async (wsMeta: WorkspaceMeta): Promise => validRuntimeForWorkspace(wsMeta, await readWorkspaceDefaultAgent().catch(() => null)) ?? firstWorkspaceRuntime(wsMeta); @@ -1482,6 +1487,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions pool, transcriptWatcher, resolveOrCreateChatWorkspace: resolveOrCreateChatWorkspaceMethod, + resolveDefaultAgentId: resolveWorkspaceDefaultAgentId, resolveAdapter, publicMeta, detectAgents,