diff --git a/apps/agent-forge/server/__tests__/authorSave.test.ts b/apps/agent-forge/server/__tests__/authorSave.test.ts new file mode 100644 index 00000000..4985987f --- /dev/null +++ b/apps/agent-forge/server/__tests__/authorSave.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import request from 'supertest'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { STUDIO_TOKEN_HEADER } from '../auth'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo, defaultMeta } from '../storage/agentRepo'; +import { specHash } from '../storage/specHash'; +import { AuthorSessionStore } from '../author/sessionStore'; +import type { AuthorTurnRunner } from '../author/turnRunner'; +import type { + AuthorAssistantMessage, + AuthorMessage, + AuthorSaveResponse, + AuthorSession, + AuthorUserMessage, + ProposalOutput, +} from '../../shared/authorWire'; + +/** + * `POST /assistant/sessions/:id/save` (AA10b): the card's Apply button. + * The stored transcript is seeded with proposal cards directly (the + * transcript IS the proposal store), then the route is exercised over + * supertest: the newest card saves, older/unknown/saved cards refuse, + * a canvas edit trips BASE_CHANGED, and a running turn answers 409. + */ +describe('POST /assistant/sessions/:id/save', () => { + const TOKEN = 'aa10b-test-token'; + let baseDir: string; + let staticDir: string; + let sessionsDir: string; + let repo: AgentRepo; + let sessions: AuthorSessionStore; + let app: ReturnType; + + const CARD_SPEC: AgentSpecV2 = { + version: 2, + name: 'Card bot', + model: 'mock/mock-1', + instructions: 'Be helpful.', + triggers: [{ id: 't1', type: 'chat' }], + }; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa10b-route-')); + staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa10b-static-')); + fs.writeFileSync(path.join(staticDir, 'index.html'), ''); + sessionsDir = path.join(baseDir, '.lousho', 'assistant', 'sessions'); + repo = new AgentRepo(baseDir); + sessions = new AuthorSessionStore(sessionsDir); + app = buildApp(); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + fs.rmSync(staticDir, { recursive: true, force: true }); + }); + + function buildApp(runner?: AuthorTurnRunner): ReturnType { + const agentStore = createFsAgentStore(baseDir, repo); + const runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + return createApp({ + agentStore, + runManager, + baseDir, + staticDir, + access: { token: TOKEN }, + sessions, + ...(runner !== undefined ? { authorRunner: runner } : {}), + }); + } + + const authed = (r: request.Test) => r.set(STUDIO_TOKEN_HEADER, TOKEN); + + async function createSession(): Promise { + const res = await authed(request(app).post('/assistant/sessions')).send({}); + expect(res.status).toBe(201); + return res.body as AuthorSession; + } + + function card(proposalId: string, over: Partial = {}): ProposalOutput { + return { + ok: true, + proposalId, + kind: 'create', + spec: CARD_SPEC, + changes: [], + requirements: [], + carriedOver: false, + carriedFields: [], + baseSpecHash: null, + enabled: false, + ...over, + }; + } + + function cardMessage( + proposalId: string, + name: 'proposeAgent' | 'proposeAgentUpdate', + input: unknown, + output: ProposalOutput + ): AuthorAssistantMessage { + return { + id: `m_${proposalId}`, + role: 'assistant', + parts: [{ type: 'tool', toolCallId: proposalId, name, input, output, state: 'done' }], + at: '2026-01-01T00:01:00.000Z', + }; + } + + async function seed(sessionId: string, messages: AuthorMessage[]): Promise { + await sessions.update(sessionId, (s) => ({ ...s, transcript: [...s.transcript, ...messages] })); + } + + function apply(sessionId: string, proposalId: unknown): request.Test { + return authed(request(app).post(`/assistant/sessions/${sessionId}/save`)).send({ proposalId }); + } + + it('saves the newest card: 200, files written disabled, transcript ends with the uiEvent message', async () => { + const session = await createSession(); + await seed(session.id, [ + { id: 'm_u1', role: 'user', text: 'build me a card bot', at: '2026-01-01T00:00:00.000Z' }, + cardMessage('call_p1', 'proposeAgent', { draft: {} }, card('call_p1')), + ]); + + const res = await apply(session.id, 'call_p1'); + expect(res.status).toBe(200); + const body = res.body as AuthorSaveResponse; + expect(body).toMatchObject({ ok: true, agentId: 'card-bot', version: 1, name: 'Card bot', enabled: false }); + expect(body.message).toMatchObject({ + role: 'user', + text: '', + uiEvent: { kind: 'save', proposalId: 'call_p1', agentId: 'card-bot', version: 1 }, + }); + + // The write: spec, disabled author-stamped meta, version 1. + expect(await repo.readSpec('card-bot')).toEqual(CARD_SPEC); + const meta = await repo.readMeta('card-bot'); + expect(meta.enabled).toBe(false); + expect(meta.createdFrom).toEqual({ kind: 'author', ref: session.id }); + + // The transcript's last message is the uiEvent the body echoed. + const stored = await sessions.get(session.id); + const last = stored?.transcript.at(-1) as AuthorUserMessage; + expect(last).toMatchObject({ + role: 'user', + text: '', + uiEvent: { kind: 'save', proposalId: 'call_p1', agentId: 'card-bot', version: 1 }, + }); + expect(last.id).toBe(body.message.id); + const version = JSON.parse( + fs.readFileSync(path.join(baseDir, '.lousho', 'agents', 'card-bot', 'versions', '000001.json'), 'utf8') + ) as { requestText: string | null; source: string }; + expect(version).toMatchObject({ source: 'author', requestText: 'build me a card bot' }); + }); + + it('a second Apply of the same card -> 400 ALREADY_SAVED', async () => { + const session = await createSession(); + await seed(session.id, [cardMessage('call_p1', 'proposeAgent', { draft: {} }, card('call_p1'))]); + expect((await apply(session.id, 'call_p1')).status).toBe(200); + + const res = await apply(session.id, 'call_p1'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'ALREADY_SAVED' }); + }); + + it('Apply on an older card -> 400 STALE_PROPOSAL naming the newest card', async () => { + const session = await createSession(); + await seed(session.id, [ + cardMessage('call_p1', 'proposeAgent', { draft: {} }, card('call_p1')), + cardMessage('call_p2', 'proposeAgent', { draft: {} }, card('call_p2')), + ]); + const res = await apply(session.id, 'call_p1'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'STALE_PROPOSAL', details: { latestProposalId: 'call_p2' } }); + expect(repo.listIds()).toEqual([]); + }); + + it('an unknown proposalId -> 400 UNKNOWN_PROPOSAL', async () => { + const session = await createSession(); + const res = await apply(session.id, 'call_nope'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'UNKNOWN_PROPOSAL' }); + }); + + it('Apply on an update card after a canvas edit -> 400 BASE_CHANGED, nothing written', async () => { + const saved: AgentSpecV2 = { ...CARD_SPEC, name: 'Triage' }; + await repo.writeSpec('triage', saved); + await repo.writeMeta('triage', defaultMeta('2026-01-01T00:00:00.000Z')); + const update = card('call_u1', { + kind: 'update', + agentId: 'triage', + spec: { ...saved, description: 'Card description' }, + baseSpecHash: specHash(saved), + enabled: true, + }); + const session = await createSession(); + await seed(session.id, [ + cardMessage('call_u1', 'proposeAgentUpdate', { agentId: 'triage', patch: { description: 'Card description' } }, update), + ]); + // The canvas (or a hand edit) moved the spec after the card was shown. + const onDisk: AgentSpecV2 = { ...saved, description: 'Canvas edit' }; + await repo.writeSpec('triage', onDisk); + + const res = await apply(session.id, 'call_u1'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'BASE_CHANGED' }); + expect(await repo.readSpec('triage')).toEqual(onDisk); + }); + + it('an update Apply writes the card spec as an author version', async () => { + const saved: AgentSpecV2 = { ...CARD_SPEC, name: 'Triage' }; + await repo.writeSpec('triage', saved); + await repo.writeMeta('triage', { ...defaultMeta('2026-01-01T00:00:00.000Z'), enabled: true }); + const update = card('call_u1', { + kind: 'update', + agentId: 'triage', + spec: { ...saved, description: 'Card description' }, + baseSpecHash: specHash(saved), + enabled: true, + }); + const session = await createSession(); + await seed(session.id, [ + cardMessage('call_u1', 'proposeAgentUpdate', { agentId: 'triage', patch: {} }, update), + ]); + + const res = await apply(session.id, 'call_u1'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ ok: true, agentId: 'triage', version: 1, enabled: true }); + expect((await repo.readSpec('triage'))?.description).toBe('Card description'); + }); + + it('404 when the session does not exist', async () => { + const res = await apply('as_0000000000000000', 'call_p1'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Chat not found.', code: 'NOT_FOUND' }); + }); + + it.each([{}, { proposalId: 5 }, { proposalId: '' }, { proposalId: 'x'.repeat(201) }, 'text'])( + 'a malformed body %j -> 400', + async (body) => { + const session = await createSession(); + const res = await authed(request(app).post(`/assistant/sessions/${session.id}/save`)).send(body as object); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_INPUT'); + } + ); + + it('409 BUSY while a reply is running in the chat', async () => { + const runningRunner = { running: () => ({ requestId: 'req-live' }) } as unknown as AuthorTurnRunner; + app = buildApp(runningRunner); + const session = await createSession(); + await seed(session.id, [cardMessage('call_p1', 'proposeAgent', { draft: {} }, card('call_p1'))]); + + const res = await apply(session.id, 'call_p1'); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'BUSY' }); + expect(repo.listIds()).toEqual([]); + }); + + it('a session whose workspace no longer resolves -> 400 AGENT_GONE', async () => { + const session = await createSession(); + await seed(session.id, [cardMessage('call_p1', 'proposeAgent', { draft: {} }, card('call_p1'))]); + await sessions.update(session.id, (s) => ({ ...s, workspaceId: 'ws-gone' })); + + const res = await apply(session.id, 'call_p1'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'AGENT_GONE' }); + expect(repo.listIds()).toEqual([]); + }); +}); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index 124020d4..074a1bd2 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -937,7 +937,7 @@ export function createApp(options: CreateAppOptions): Express { registerMcpProbeRoute(app, probeDeps); // Agent Author chat sessions (Studio-global files under // studioPaths().sessions; each session records which workspace it edits). - registerAuthorRoutes(app, { sessions, runner: authorRunner }); + registerAuthorRoutes(app, { sessions, runner: authorRunner, workspaceResolver }); // The Author's own settings (author/settings.ts): which model its turns // run on and whether a key for it exists. registerAuthorSettingsRoutes(app, { settings, secrets }); diff --git a/apps/agent-forge/server/author/__tests__/saveFlow.test.ts b/apps/agent-forge/server/author/__tests__/saveFlow.test.ts new file mode 100644 index 00000000..3cf3f627 --- /dev/null +++ b/apps/agent-forge/server/author/__tests__/saveFlow.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { mockModel, type MockModel } from 'lousho/testing'; +import type { AgentSpecV2 } from 'lousho'; +import { AuthorSessionStore } from '../sessionStore'; +import { SettingsStore } from '../../settingsStore'; +import { SecretsStore } from '../../secretsStore'; +import { AuthorTurnRunner } from '../turnRunner'; +import { buildAuthorTools, type AuthorToolDeps, type CatalogService } from '../tools/index'; +import { passReview } from '../proposal/review'; +import { AgentRepo, defaultMeta } from '../../storage/agentRepo'; +import { VersionStore } from '../../storage/versionStore'; +import { StudioEventBus } from '../../events/eventBus'; +import type { WorkspaceContext } from '../../workspace/context'; +import type { AuthorSession, AuthorServerEvent, AuthorToolPart, ProposalOutput } from '../../../shared/authorWire'; +import type { AuthorDraft } from '../draft/schema'; + +/** + * The end-to-end save flow (AA10b): a real AuthorTurnRunner drives the + * real propose + save tools over a real workspace, scripted by mockModel. + * Turn 1 proposes (a card lands in the stored transcript), turn 2 saves - + * and everything the write produces is on disk, not in the model's input. + */ + +const DRAFT: AuthorDraft = { + name: 'Support triage', + description: 'Routes support mail to the right queue.', + model: 'mock/mock-1', + instructions: 'You triage support mail and pick the queue.', +}; + +function fakeCatalog(): CatalogService { + return { + listModels: () => [{ id: 'mock/mock-1' }, { id: 'anthropic/claude-sonnet-5-5' }] as never, + listTools: () => [] as never, + listGuardrails: () => [], + listTriggerTypes: () => [{ type: 'chat' }] as never, + listSkills: async () => [], + listKits: async () => [], + listRegistryMcpServers: async () => [], + }; +} + +describe('Author save flow', () => { + let dir: string; + let repo: AgentRepo; + let store: AuthorSessionStore; + let session: AuthorSession; + let deps: AuthorToolDeps; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa10b-flow-')); + repo = new AgentRepo(dir); + store = new AuthorSessionStore(path.join(dir, 'sessions')); + const workspace = { + repo, + versions: new VersionStore(repo), + events: new StudioEventBus(), + } as unknown as WorkspaceContext; + deps = { + workspace: async () => workspace, + sessions: store, + catalog: fakeCatalog(), + templates: { list: async () => [] }, + probeMcp: async () => ({ ok: true, tools: [] }), + docsDir: '/nonexistent-docs', + reviewDraft: passReview, + requirements: async () => [], + }; + session = await store.create(undefined, 'ws-test'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function makeRunner(model: MockModel): AuthorTurnRunner { + return new AuthorTurnRunner({ + sessions: store, + settings: new SettingsStore(dir), + secrets: new SecretsStore(dir), + systemPrompt: () => 'You are the lousho Agent Author.', + buildTools: (turn) => buildAuthorTools(turn, deps), + turnContextInput: async (s, now) => ({ focus: s.focus, openProposals: [], now }), + providerFor: () => model, + }); + } + + function turnEnd(runner: AuthorTurnRunner): Promise { + return new Promise((resolve) => { + runner.on('event', (_sid, ev) => { + if (ev.type === 'turn.finished' || ev.type === 'turn.error') resolve(ev); + }); + }); + } + + /** Every tool part across the stored transcript's assistant messages. */ + async function toolParts(): Promise { + const stored = await store.get(session.id); + return (stored?.transcript ?? []).flatMap((message) => + message.role === 'assistant' ? message.parts.filter((part): part is AuthorToolPart => part.type === 'tool') : [] + ); + } + + /** The output of the done tool part named `name` with call id `toolCallId`. */ + async function toolOutput(name: string, toolCallId: string): Promise> { + const part = (await toolParts()).find((p) => p.name === name && p.toolCallId === toolCallId); + expect(part?.state).toBe('done'); + return part?.output as Record; + } + + it('turn 1 proposes, turn 2 saves: files, meta, version and session link all land', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'proposeAgent', args: { draft: DRAFT }, id: 'call_p1' }] }, + { text: 'Here is the card.' }, + { toolCalls: [{ name: 'saveAgent', args: { proposalId: 'call_p1' }, id: 'call_s1' }] }, + { text: 'Saved.' }, + ]); + const runner = makeRunner(model); + + let end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t1', text: 'build me a support triage bot' }); + await end; + + end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t2', text: 'yes, save it' }); + await end; + + // The save tool's result. + expect(await toolOutput('saveAgent', 'call_s1')).toMatchObject({ + ok: true, + agentId: 'support-triage', + version: 1, + name: 'Support triage', + enabled: false, + }); + + // The agent file exists, deep-equal to the stored card's spec. + const card = (await toolOutput('proposeAgent', 'call_p1')) as unknown as ProposalOutput; + expect(await repo.readSpec('support-triage')).toEqual(card.spec); + + // New agents save disabled and carry the author provenance. + const meta = await repo.readMeta('support-triage'); + expect(meta.enabled).toBe(false); + expect(meta.createdFrom).toEqual({ kind: 'author', ref: session.id }); + + // Version 1 is an author version naming the user text that asked for it. + const versions = new VersionStore(repo); + const [item] = await versions.list('support-triage'); + expect(item).toMatchObject({ + version: 1, + kind: 'create', + source: 'author', + requestText: 'build me a support triage bot', + authorSessionId: session.id, + }); + + // The session records the agent it created. + expect((await store.get(session.id))?.createdAgentIds).toEqual(['support-triage']); + }); + + it('propose and save in the same turn -> UNANSWERED, nothing written', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'proposeAgent', args: { draft: DRAFT }, id: 'call_p1' }] }, + { toolCalls: [{ name: 'saveAgent', args: { proposalId: 'call_p1' }, id: 'call_s1' }] }, + { text: 'Done.' }, + ]); + const runner = makeRunner(model); + const end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t1', text: 'make it and save it' }); + await end; + + expect(await toolOutput('saveAgent', 'call_s1')).toMatchObject({ ok: false, code: 'UNANSWERED' }); + expect(repo.listIds()).toEqual([]); + expect((await store.get(session.id))?.createdAgentIds).toEqual([]); + }); + + it('a second save of the same card in one turn -> ALREADY_SAVED', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'proposeAgent', args: { draft: DRAFT }, id: 'call_p1' }] }, + { text: 'Here is the card.' }, + { toolCalls: [{ name: 'saveAgent', args: { proposalId: 'call_p1' }, id: 'call_s1' }] }, + { toolCalls: [{ name: 'saveAgent', args: { proposalId: 'call_p1' }, id: 'call_s2' }] }, + { text: 'Done.' }, + ]); + const runner = makeRunner(model); + + let end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t1', text: 'build the bot' }); + await end; + end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t2', text: 'save it, twice' }); + await end; + + expect(await toolOutput('saveAgent', 'call_s1')).toMatchObject({ ok: true, agentId: 'support-triage' }); + expect(await toolOutput('saveAgent', 'call_s2')).toMatchObject({ ok: false, code: 'ALREADY_SAVED' }); + // Exactly one agent and exactly one version landed. + expect(repo.listIds()).toEqual(['support-triage']); + expect(await new VersionStore(repo).list('support-triage')).toHaveLength(1); + }); + + it('saveAgentUpdate writes the update card over the saved spec', async () => { + // An agent already on disk, then one turn proposes the update and the next saves it. + const saved: AgentSpecV2 = { + version: 2, + name: 'Triage', + model: 'mock/mock-1', + instructions: 'You triage.', + triggers: [{ id: 't1', type: 'chat' }], + }; + await repo.writeSpec('triage', saved); + await repo.writeMeta('triage', { ...defaultMeta('2026-01-01T00:00:00.000Z'), enabled: false }); + const model = mockModel([ + { toolCalls: [{ name: 'proposeAgentUpdate', args: { agentId: 'triage', patch: { description: 'Files bugs too.' } }, id: 'call_u1' }] }, + { text: 'Here is the card.' }, + { toolCalls: [{ name: 'saveAgentUpdate', args: { agentId: 'triage', proposalId: 'call_u1' }, id: 'call_s1' }] }, + { text: 'Saved.' }, + ]); + const runner = makeRunner(model); + + let end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t1', text: 'change the description' }); + await end; + end = turnEnd(runner); + await runner.start(session.id, { requestId: 'req-aa10b-t2', text: 'apply it' }); + await end; + + expect(await toolOutput('saveAgentUpdate', 'call_s1')).toMatchObject({ + ok: true, + agentId: 'triage', + version: 1, + enabled: false, + }); + expect((await repo.readSpec('triage'))?.description).toBe('Files bugs too.'); + const [item] = await new VersionStore(repo).list('triage'); + expect(item).toMatchObject({ version: 1, kind: 'update', source: 'author' }); + }); +}); diff --git a/apps/agent-forge/server/author/draft/toSpec.ts b/apps/agent-forge/server/author/draft/toSpec.ts index ad5bf7c2..bae53fa6 100644 --- a/apps/agent-forge/server/author/draft/toSpec.ts +++ b/apps/agent-forge/server/author/draft/toSpec.ts @@ -45,8 +45,13 @@ function flattenPath(issue: SpecSchemaIssue): string { return path === '' ? '(root)' : path; } -/** `agentSpecV2Schema.safeParse` of a candidate spec as a {@link SpecBuildResult}. */ -function validateSpec(candidate: unknown): SpecBuildResult { +/** + * `agentSpecV2Schema.safeParse` of a candidate spec as a + * {@link SpecBuildResult}. Exported for the save path (proposal/save.ts), + * which re-validates a stored card's spec with today's schema the same + * way a draft is validated at propose time. + */ +export function validateSpec(candidate: unknown): SpecBuildResult { const parsed = agentSpecV2Schema.safeParse(candidate); if (parsed.success) return { ok: true, spec: parsed.data }; return { diff --git a/apps/agent-forge/server/author/proposal/__tests__/catalogChecks.test.ts b/apps/agent-forge/server/author/proposal/__tests__/catalogChecks.test.ts index d61007c2..5055b976 100644 --- a/apps/agent-forge/server/author/proposal/__tests__/catalogChecks.test.ts +++ b/apps/agent-forge/server/author/proposal/__tests__/catalogChecks.test.ts @@ -5,6 +5,7 @@ import type { AuthorTurn } from '../../turnRunner'; import type { WorkspaceContext } from '../../../workspace/context'; import type { AgentRepo } from '../../../storage/agentRepo'; import type { AuthorToolFailure } from '../../../../shared/authorWire'; +import { AuthorSessionStore } from '../../sessionStore'; import { checkCatalogs, loadCatalogFacts, type CatalogFacts } from '../catalogChecks'; /** @@ -173,6 +174,7 @@ describe('loadCatalogFacts', () => { docsDir: '/nonexistent-docs', reviewDraft: async () => ({ pass: true }), requirements: async () => [], + sessions: new AuthorSessionStore('/unused-sessions'), }; } diff --git a/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts b/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts index 08901632..3f56d2b9 100644 --- a/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts +++ b/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from 'vitest'; import type { AuthorToolFailure } from '../../../../shared/authorWire'; import { + agentGoneFailure, alreadySavedFailure, ambiguousProposalFailure, + baseChangedFailure, + invalidStoredProposalFailure, noProposalFailure, staleProposalFailure, unansweredFailure, unknownProposalFailure, + writeFailedFailure, wrongAgentFailure, } from '../failures'; @@ -31,6 +35,10 @@ const ALL: AuthorToolFailure[] = [ wrongAgentFailure('call_u1', 'agent-a', 'agent-b'), ambiguousProposalFailure('create', 'call_p1'), ambiguousProposalFailure('update', 'call_p1'), + invalidStoredProposalFailure([{ path: 'model', message: 'Required' }]), + baseChangedFailure('agent-a'), + agentGoneFailure('agent-a'), + writeFailedFailure('disk full'), ]; describe('save-gate failure copy', () => { @@ -57,6 +65,12 @@ describe('save-gate failure copy', () => { expect(staleProposalFailure('call_p1', 'call_p2').code).toBe('STALE_PROPOSAL'); expect(wrongAgentFailure('call_u1', 'agent-a', 'agent-b').code).toBe('WRONG_AGENT'); expect(ambiguousProposalFailure('create', 'call_p1').code).toBe('AMBIGUOUS_PROPOSAL'); + expect(invalidStoredProposalFailure([{ path: 'model', message: 'Required' }]).code).toBe( + 'INVALID_STORED_PROPOSAL' + ); + expect(baseChangedFailure('agent-a').code).toBe('BASE_CHANGED'); + expect(agentGoneFailure('agent-a').code).toBe('AGENT_GONE'); + expect(writeFailedFailure('disk full').code).toBe('WRITE_FAILED'); }); it('the model copy interpolates the proposal ids it names', () => { diff --git a/apps/agent-forge/server/author/proposal/__tests__/save.test.ts b/apps/agent-forge/server/author/proposal/__tests__/save.test.ts new file mode 100644 index 00000000..469b027c --- /dev/null +++ b/apps/agent-forge/server/author/proposal/__tests__/save.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { agentSpecV2Schema, type AgentSpecV2 } from 'lousho'; +import type { + AuthorAssistantMessage, + AuthorMessage, + AuthorToolFailure, + AuthorUserMessage, + ProposalOutput, +} from '../../../../shared/authorWire'; +import type { StudioEvent } from '../../../../shared/wireTypes'; +import { AgentRepo, defaultMeta } from '../../../storage/agentRepo'; +import { VersionStore } from '../../../storage/versionStore'; +import { specHash } from '../../../storage/specHash'; +import { StudioEventBus } from '../../../events/eventBus'; +import { AuthorSessionStore } from '../../sessionStore'; +import { requestTextFor, writeProposal, type WriteContext } from '../save'; + +/** + * writeProposal + requestTextFor (AA10b): the one write path behind the + * save tools and the card's Apply button. Real AgentRepo / VersionStore / + * AuthorSessionStore over a temp dir, so the assertions read the files + * Studio would actually write. + */ + +const BASE_SPEC: AgentSpecV2 = agentSpecV2Schema.parse({ + version: 2, + name: 'Support triage', + description: 'Routes support mail.', + model: 'mock/mock-1', + instructions: 'You triage support mail.', + triggers: [{ id: 't1', type: 'chat' }], +}) as AgentSpecV2; + +function card(proposalId: string, over: Partial = {}): ProposalOutput { + return { + ok: true, + proposalId, + kind: 'create', + spec: BASE_SPEC, + changes: [], + requirements: [], + carriedOver: false, + carriedFields: [], + baseSpecHash: null, + enabled: false, + ...over, + }; +} + +function userMsg(text: string, id = 'm_u1'): AuthorUserMessage { + return { id, role: 'user', text, at: '2026-01-01T00:00:00.000Z' }; +} + +function uiSave(proposalId: string): AuthorUserMessage { + return { + id: 'm_ui', + role: 'user', + text: '', + at: '2026-01-01T00:01:00.000Z', + uiEvent: { kind: 'save', proposalId, agentId: 'a', version: 1 }, + }; +} + +function assistantCard(proposalId: string): AuthorAssistantMessage { + return { + id: 'm_a1', + role: 'assistant', + parts: [ + { type: 'tool', toolCallId: proposalId, name: 'proposeAgent', input: { draft: {} }, output: { ok: true }, state: 'done' }, + ], + at: '2026-01-01T00:02:00.000Z', + }; +} + +describe('requestTextFor', () => { + it('returns the last non-empty user text before the assistant message holding the card', () => { + const messages: AuthorMessage[] = [ + userMsg('first ask', 'm_u1'), + assistantCard('call_older'), + userMsg('second ask', 'm_u2'), + assistantCard('call_p1'), + ]; + expect(requestTextFor(messages, 'call_p1')).toBe('second ask'); + expect(requestTextFor(messages, 'call_older')).toBe('first ask'); + }); + + it('cuts to 400 chars with an ellipsis', () => { + const messages: AuthorMessage[] = [userMsg('x'.repeat(500)), assistantCard('call_p1')]; + const text = requestTextFor(messages, 'call_p1'); + expect(text).toHaveLength(400); + expect(text?.endsWith('…')).toBe(true); + }); + + it('skips uiEvent user messages', () => { + const withUi: AuthorMessage[] = [userMsg('the real ask'), uiSave('call_p0'), assistantCard('call_p1')]; + expect(requestTextFor(withUi, 'call_p1')).toBe('the real ask'); + const onlyUi: AuthorMessage[] = [uiSave('call_p0'), assistantCard('call_p1')]; + expect(requestTextFor(onlyUi, 'call_p1')).toBeNull(); + }); + + it('returns null when the card or a preceding user text is absent', () => { + expect(requestTextFor([userMsg('hi'), assistantCard('call_p1')], 'call_nope')).toBeNull(); + expect(requestTextFor([assistantCard('call_p1')], 'call_p1')).toBeNull(); + expect(requestTextFor([userMsg(' '), assistantCard('call_p1')], 'call_p1')).toBeNull(); + }); +}); + +describe('writeProposal', () => { + let dir: string; + let sessionsDir: string; + let repo: AgentRepo; + let versions: VersionStore; + let sessions: AuthorSessionStore; + let bus: StudioEventBus; + let published: StudioEvent[]; + let sessionId: string; + + const savedSpec: AgentSpecV2 = { ...BASE_SPEC, name: 'Existing bot' }; + + beforeEach(async () => { + dir = mkdtempSync(path.join(tmpdir(), 'lou-aa10b-save-')); + sessionsDir = path.join(dir, 'sessions'); + repo = new AgentRepo(dir); + versions = new VersionStore(repo); + sessions = new AuthorSessionStore(sessionsDir); + bus = new StudioEventBus(); + published = []; + bus.subscribe((event) => published.push(event)); + sessionId = (await sessions.create(undefined, 'ws-test')).id; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function ctx(over: Partial = {}): WriteContext { + return { sessionId, requestText: 'make me a bot', agents: repo, versions, sessions, bus, ...over }; + } + + function failure(result: unknown): AuthorToolFailure { + expect(result).toMatchObject({ ok: false }); + return result as AuthorToolFailure; + } + + it('create: writes the card spec exactly, disabled, author-stamped, versioned and linked to the session', async () => { + const result = await writeProposal({ kind: 'create' }, card('call_p1'), ctx()); + expect(result).toEqual({ + ok: true, + agentId: 'support-triage', + version: 1, + name: 'Support triage', + enabled: false, + }); + + // The written spec deep-equals the card's spec (no re-sent copy). + expect(await repo.readSpec('support-triage')).toEqual(BASE_SPEC); + const meta = await repo.readMeta('support-triage'); + expect(meta.enabled).toBe(false); + expect(meta.createdFrom).toEqual({ kind: 'author', ref: sessionId }); + + const [item] = await versions.list('support-triage'); + expect(item).toMatchObject({ + version: 1, + kind: 'create', + source: 'author', + requestText: 'make me a bot', + authorSessionId: sessionId, + }); + + const session = await sessions.get(sessionId); + expect(session?.createdAgentIds).toEqual(['support-triage']); + expect(published).toEqual([{ type: 'agent.changed', agentId: 'support-triage', reason: 'version' }]); + }); + + it('update: writes the card spec, captures an author version and reports the saved meta.enabled', async () => { + await repo.writeSpec('a', savedSpec); + await repo.writeMeta('a', { ...defaultMeta('2026-01-01T00:00:00.000Z'), enabled: true }); + await versions.capture('a', { kind: 'create', source: 'editor' }); + const updated: AgentSpecV2 = { ...savedSpec, description: 'Now it also files bugs.' }; + + const result = await writeProposal( + { kind: 'update', agentId: 'a' }, + card('call_u1', { kind: 'update', agentId: 'a', spec: updated, baseSpecHash: specHash(savedSpec) }), + ctx({ requestText: 'also file bugs' }) + ); + expect(result).toEqual({ ok: true, agentId: 'a', version: 2, name: 'Existing bot', enabled: true }); + expect(await repo.readSpec('a')).toEqual(updated); + const [item] = await versions.list('a'); + expect(item).toMatchObject({ + version: 2, + kind: 'update', + source: 'author', + requestText: 'also file bugs', + authorSessionId: sessionId, + }); + expect(published).toEqual([{ type: 'agent.changed', agentId: 'a', reason: 'version' }]); + }); + + it('update on a spec the store already holds reports the latest version (no-op skip)', async () => { + await repo.writeSpec('a', savedSpec); + await repo.writeMeta('a', { ...defaultMeta('2026-01-01T00:00:00.000Z'), enabled: true }); + await versions.capture('a', { kind: 'create', source: 'editor' }); + + const result = await writeProposal( + { kind: 'update', agentId: 'a' }, + card('call_u1', { kind: 'update', agentId: 'a', spec: savedSpec, baseSpecHash: specHash(savedSpec) }), + ctx() + ); + // The identical write is a no-op capture: the result names the existing version. + expect(result).toMatchObject({ ok: true, version: 1 }); + expect(await versions.list('a')).toHaveLength(1); + }); + + it('update after a canvas edit refuses BASE_CHANGED and writes nothing', async () => { + await repo.writeSpec('a', savedSpec); + await repo.writeMeta('a', defaultMeta('2026-01-01T00:00:00.000Z')); + await versions.capture('a', { kind: 'create', source: 'editor' }); + // The canvas moved the spec after the card was proposed. + const onCanvas: AgentSpecV2 = { ...savedSpec, description: 'Canvas edit' }; + await repo.writeSpec('a', onCanvas); + + const result = failure( + await writeProposal( + { kind: 'update', agentId: 'a' }, + card('call_u1', { + kind: 'update', + agentId: 'a', + spec: { ...savedSpec, description: 'Card edit' }, + baseSpecHash: specHash(savedSpec), + }), + ctx() + ) + ); + expect(result.code).toBe('BASE_CHANGED'); + expect(await repo.readSpec('a')).toEqual(onCanvas); + expect(await versions.list('a')).toHaveLength(1); + }); + + it('update on a deleted agent refuses AGENT_GONE', async () => { + const result = failure( + await writeProposal( + { kind: 'update', agentId: 'ghost' }, + card('call_u1', { kind: 'update', agentId: 'ghost', baseSpecHash: 'sha256:any' }), + ctx() + ) + ); + expect(result.code).toBe('AGENT_GONE'); + expect(result.error).toContain('"ghost"'); + }); + + it('a tampered stored spec refuses INVALID_STORED_PROPOSAL', async () => { + const tampered = card('call_p1', { spec: { version: 2, name: 'x' } as unknown as AgentSpecV2 }); + const result = failure(await writeProposal({ kind: 'create' }, tampered, ctx())); + expect(result.code).toBe('INVALID_STORED_PROPOSAL'); + expect(result.error).toContain('AgentSpec checks'); + expect(repo.listIds()).toEqual([]); + }); + + it('a throwing store becomes WRITE_FAILED', async () => { + const throwing = { + listIds: () => [], + writeSpec: async () => { + throw new Error('disk full'); + }, + } as unknown as AgentRepo; + const result = failure(await writeProposal({ kind: 'create' }, card('call_p1'), ctx({ agents: throwing }))); + expect(result.code).toBe('WRITE_FAILED'); + expect(result.error).toContain('disk full'); + // A failure never marks the session's createdAgentIds. + expect((await sessions.get(sessionId))?.createdAgentIds).toEqual([]); + }); +}); diff --git a/apps/agent-forge/server/author/proposal/failures.ts b/apps/agent-forge/server/author/proposal/failures.ts index cfd529e5..f99646a3 100644 --- a/apps/agent-forge/server/author/proposal/failures.ts +++ b/apps/agent-forge/server/author/proposal/failures.ts @@ -1,12 +1,14 @@ /** - * The save gate's refusal copy (PLAN §8.3 "Save refuses when ...", research - * §5.2): one builder per failure code the proposal gate can return. Like - * every Author tool failure, `error` and `next` are model-facing - they may - * name tools and ids so the model can recover - while `userMessage` is - * person-facing plain words and never contains a tool name or an id - * (`__tests__/failures.test.ts` scans for them). + * The save path's refusal copy (PLAN §8.3 "Save refuses when ...", research + * §5.2): one builder per failure code the proposal gate (AA10a) or the + * write itself (AA10b) can return. Like every Author tool failure, `error` + * and `next` are model-facing - they may name tools and ids so the model + * can recover - while `userMessage` is person-facing plain words and never + * contains a tool name or an id (`__tests__/failures.test.ts` scans for + * them). */ import type { AuthorToolFailure, ProposalKind } from '../../../shared/authorWire'; +import type { SpecIssue } from '../draft/toSpec'; import { toolFailure } from '../tools/failure'; /** @@ -92,3 +94,52 @@ export function ambiguousProposalFailure(kind: ProposalKind, proposalId: string) `Call ${PROPOSE_TOOL[kind]} again with the configuration the user agreed to, let them confirm the new card, and save that one.` ); } + +/** + * `INVALID_STORED_PROPOSAL` (AA10b): the stored card's spec no longer + * passes `agentSpecV2Schema` under today's rules. `issues` are AA07's + * flattened `{ path, message }` rows; the first ten go in the model copy + * like {@link draftInvalidFailure}'s. + */ +export function invalidStoredProposalFailure(issues: SpecIssue[]): AuthorToolFailure { + const detail = issues + .slice(0, 10) + .map((issue) => `${issue.path}: ${issue.message}`) + .join('; '); + return toolFailure( + 'INVALID_STORED_PROPOSAL', + `The stored card no longer passes today's AgentSpec checks: ${detail}.`, + "This proposal no longer passes Studio's checks, so it can't be saved as shown. Ask the Author to propose it again.", + 'Propose it again, let the user review the new card, and save that one.' + ); +} + +/** `BASE_CHANGED` (AA10b): the agent's saved spec moved after the update card was proposed. */ +export function baseChangedFailure(agentId: string): AuthorToolFailure { + return toolFailure( + 'BASE_CHANGED', + `Agent "${agentId}" changed after this card was proposed, for example on the canvas.`, + 'This agent changed after the proposal was made. Ask the Author to propose the change again on the current version.', + 'Call getCurrentSpec, propose the change again on the current spec, and wait for confirmation.' + ); +} + +/** `AGENT_GONE` (AA10b): the update's agent (or the session's workspace) no longer exists. */ +export function agentGoneFailure(agentId: string): AuthorToolFailure { + return toolFailure( + 'AGENT_GONE', + `Agent "${agentId}" no longer exists.`, + "This agent was deleted, so the change can't be saved.", + 'Tell the user, and offer to create it again.' + ); +} + +/** `WRITE_FAILED` (AA10b): the repo, version store or session store threw mid-write. */ +export function writeFailedFailure(message: string): AuthorToolFailure { + return toolFailure( + 'WRITE_FAILED', + `Studio could not write the agent: ${message}.`, + "Studio couldn't save the agent. Try again.", + 'Tell the user the save failed and quote the error. Do not retry more than once.' + ); +} diff --git a/apps/agent-forge/server/author/proposal/save.ts b/apps/agent-forge/server/author/proposal/save.ts new file mode 100644 index 00000000..24c7fc9a --- /dev/null +++ b/apps/agent-forge/server/author/proposal/save.ts @@ -0,0 +1,185 @@ +/** + * The one write path behind the Author's save tools and the card's Apply + * button (PLAN §8.3, AA10b): `writeProposal` writes EXACTLY the stored + * card's spec - re-validated with today's `agentSpecV2Schema`, never a + * re-sent copy and never the model's tool input - captures a + * `source: 'author'` version carrying the user text that asked for the + * card, saves new agents disabled, and links created agents back to the + * session. Every failure is a returned `AuthorToolFailure`, never thrown + * (a throwing store becomes `WRITE_FAILED`). + * + * The durable proposal store is the session transcript; the gate + * (gate.ts) decides WHICH card may save - this module only performs the + * write itself once a caller has resolved one. + */ +import type { AgentSpecV2 } from 'lousho'; +import type { + AuthorMessage, + AuthorToolFailure, + ProposalOutput, +} from '../../../shared/authorWire'; +import type { VersionListItem } from '../../../shared/wireTypes'; +import type { StudioEventBus } from '../../events/eventBus'; +import { defaultMeta, type AgentRepo } from '../../storage/agentRepo'; +import { withAgentLock } from '../../storage/atomic'; +import { specHash } from '../../storage/specHash'; +import type { VersionStore } from '../../storage/versionStore'; +import { deriveAgentId, validateSpec } from '../draft/toSpec'; +import type { AuthorSessionStore } from '../sessionStore'; +import { + agentGoneFailure, + baseChangedFailure, + invalidStoredProposalFailure, + writeFailedFailure, +} from './failures'; +import type { ProposalIdentity } from './turnState'; + +/** The longest `requestText` a write stamps on its version (PLAN §4, 400 chars). */ +const REQUEST_TEXT_MAX = 400; + +/** + * The stores and per-save facts `writeProposal` needs. `agents`, + * `versions` and `bus` come off the session's `WorkspaceContext` (the + * PLAN §19.3 seam - resolved by the caller from `session.workspaceId`, + * never a closed-over singleton); `sessions` is the Studio-global + * `AuthorSessionStore` so a create can record `createdAgentIds`. + */ +export interface WriteContext { + sessionId: string; + /** The user text that led to the card (`requestTextFor`), already cut. */ + requestText: string | null; + agents: AgentRepo; + versions: VersionStore; + sessions: AuthorSessionStore; + bus?: StudioEventBus; +} + +/** `writeProposal`'s answer: the saved agent's id/version/name/enabled, or why it refused. */ +export type SaveResult = + | { ok: true; agentId: string; version: number; name: string; enabled: boolean } + | AuthorToolFailure; + +/** + * The last non-empty user text before the assistant message holding + * `proposalId`, cut to 400 chars ('…' when cut). `uiEvent` user messages + * are skipped - they are app-written save/secret markers, not something + * the user asked for. `null` when no card carries the id or no user text + * precedes it. + */ +export function requestTextFor(messages: readonly AuthorMessage[], proposalId: string): string | null { + let lastUserText: string | null = null; + for (const message of messages) { + if (message.role === 'user') { + if (message.uiEvent === undefined && message.text.trim() !== '') lastUserText = message.text; + continue; + } + const holdsCard = message.parts.some((part) => part.type === 'tool' && part.toolCallId === proposalId); + if (holdsCard) { + if (lastUserText === null) return null; + return lastUserText.length > REQUEST_TEXT_MAX + ? `${lastUserText.slice(0, REQUEST_TEXT_MAX - 1)}…` + : lastUserText; + } + } + return null; +} + +/** The `{ ok: true }` half of a save: the version number (a no-op update re-uses the latest) and the bus event. */ +async function savedResult( + ctx: WriteContext, + agentId: string, + name: string, + enabled: boolean, + captured: VersionListItem | null +): Promise { + // `capture` returns null only for a no-op update, which by definition + // has a latest item to fall back to. + const item = captured ?? (await ctx.versions.latest(agentId)); + if (item === undefined) throw new Error(`Version capture for '${agentId}' recorded nothing.`); + ctx.bus?.publish({ type: 'agent.changed', agentId, reason: 'version' }); + return { ok: true, agentId, version: item.version, name, enabled }; +} + +/** + * The create branch: a fresh id from the card's name, then spec + meta + + * version 1 inside the new agent's lock - disabled, `createdFrom` + * pointing back at the Author session. `sessions.update` runs AFTER the + * lock is released (it takes the `author:` lock itself). + */ +async function writeCreate(spec: AgentSpecV2, ctx: WriteContext): Promise { + const id = deriveAgentId(spec.name, new Set(ctx.agents.listIds())); + let captured: VersionListItem | null = null; + await withAgentLock(id, async () => { + await ctx.agents.writeSpec(id, spec); + await ctx.agents.writeMeta(id, { + ...defaultMeta(new Date().toISOString()), + enabled: false, + createdFrom: { kind: 'author', ref: ctx.sessionId }, + }); + captured = await ctx.versions.capture(id, { + kind: 'create', + source: 'author', + requestText: ctx.requestText, + authorSessionId: ctx.sessionId, + }); + }); + await ctx.sessions.update(ctx.sessionId, (s) => ({ + ...s, + createdAgentIds: s.createdAgentIds.includes(id) ? s.createdAgentIds : [...s.createdAgentIds, id], + })); + return savedResult(ctx, id, spec.name, false, captured); +} + +/** + * The update branch: inside the agent's lock, the working copy must still + * exist (`AGENT_GONE`) and still hash to the card's `baseSpecHash` + * (`BASE_CHANGED` - a canvas edit after the card was shown) before the + * stored spec is written and versioned. + */ +async function writeUpdate(agentId: string, spec: AgentSpecV2, output: ProposalOutput, ctx: WriteContext): Promise { + let captured: VersionListItem | null = null; + let enabled = false; + let refusal: AuthorToolFailure | undefined; + await withAgentLock(agentId, async () => { + const current = await ctx.agents.readSpec(agentId); + if (current === undefined) { + refusal = agentGoneFailure(agentId); + return; + } + if (specHash(current) !== output.baseSpecHash) { + refusal = baseChangedFailure(agentId); + return; + } + enabled = (await ctx.agents.readMeta(agentId)).enabled; + await ctx.agents.writeSpec(agentId, spec); + captured = await ctx.versions.capture(agentId, { + kind: 'update', + source: 'author', + requestText: ctx.requestText, + authorSessionId: ctx.sessionId, + }); + }); + if (refusal !== undefined) return refusal; + return savedResult(ctx, agentId, spec.name, enabled, captured); +} + +/** + * The one write path for model saves and the Apply button (PLAN §8.3): + * re-validates the stored card's spec (`INVALID_STORED_PROPOSAL`), then + * writes it exactly. Any thrown error becomes `WRITE_FAILED` - a save + * refuses by returning, never by throwing. + */ +export async function writeProposal( + identity: ProposalIdentity, + output: ProposalOutput, + ctx: WriteContext +): Promise { + const built = validateSpec(output.spec); + if (!built.ok) return invalidStoredProposalFailure(built.issues); + try { + if (identity.kind === 'create') return await writeCreate(built.spec, ctx); + return await writeUpdate(identity.agentId, built.spec, output, ctx); + } catch (error) { + return writeFailedFailure(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/agent-forge/server/author/routes.ts b/apps/agent-forge/server/author/routes.ts index 851ebce0..7b45ed39 100644 --- a/apps/agent-forge/server/author/routes.ts +++ b/apps/agent-forge/server/author/routes.ts @@ -13,11 +13,23 @@ * Turns, the WebSocket stream, saves and secrets are later tickets; this * is the persisted-session CRUD they all build on. */ +import { randomUUID } from 'node:crypto'; import type { Express, Request, Response } from 'express'; -import { AUTHOR_SESSION_ID_RE, type AuthorFocus, type AuthorSessionRecord } from '../../shared/authorWire'; +import { + AUTHOR_SESSION_ID_RE, + type AuthorFocus, + type AuthorSaveResponse, + type AuthorSessionRecord, + type AuthorToolFailure, + type AuthorUserMessage, +} from '../../shared/authorWire'; import { isValidAgentId } from '../../shared/agentId'; import { asyncRoute, paramId, sendError } from '../http/routeUtils'; import { workspaceOf } from '../workspace/middleware'; +import type { WorkspaceResolver } from '../workspace/context'; +import { identityOfProposal, resolveStoredProposal } from './proposal/gate'; +import { agentGoneFailure, unknownProposalFailure } from './proposal/failures'; +import { requestTextFor, writeProposal } from './proposal/save'; import type { AuthorSessionStore } from './sessionStore'; import type { AuthorTurnRunner } from './turnRunner'; @@ -54,6 +66,32 @@ function sessionIdParam(req: Request, res: Response): string | undefined { return id; } +/** + * The `proposalId` a `POST .../save` body carries, or `undefined` once the + * 400 was sent: a string of 1-200 chars, copied verbatim off the card. + */ +function proposalIdOf(body: unknown, res: Response): string | undefined { + const proposalId = + typeof body === 'object' && body !== null ? (body as { proposalId?: unknown }).proposalId : undefined; + if (typeof proposalId !== 'string' || proposalId.length === 0 || proposalId.length > 200) { + sendError(res, 400, 'Request body must be { proposalId: string } (1-200 characters).', 'INVALID_INPUT'); + return undefined; + } + return proposalId; +} + +/** + * A failed save's HTTP answer: the person-facing `userMessage` and the + * failure `code` (`latestProposalId` travels as `details` so the client + * can point at the newer card). A `WRITE_FAILED` is a server fault - 500; + * every refusal is a 400. + */ +function sendSaveFailure(res: Response, failure: AuthorToolFailure): void { + const details = + typeof failure.latestProposalId === 'string' ? { latestProposalId: failure.latestProposalId } : undefined; + sendError(res, failure.code === 'WRITE_FAILED' ? 500 : 400, failure.userMessage, failure.code, details); +} + /** * Registers the four session routes. `deps.sessions` is the app's single * `AuthorSessionStore` (sessions are Studio-global); the workspace the @@ -65,8 +103,23 @@ function sessionIdParam(req: Request, res: Response): string | undefined { */ export function registerAuthorRoutes( app: Express, - deps: { sessions: AuthorSessionStore; runner?: Pick } + deps: { + sessions: AuthorSessionStore; + runner?: Pick; + /** + * The app's workspace resolver (PLAN §19.3): `POST .../save` resolves + * the stored `session.workspaceId` through it to reach the agent + * stores - never the request's workspace header. + */ + workspaceResolver: WorkspaceResolver; + } ): void { + /** + * Session ids with an Apply save in flight: a second Apply while the + * first still writes is a 409, same as a running reply (the click is + * the confirmation, and one confirmation buys one save). + */ + const savesInFlight = new Set(); // Create a chat: 201 AuthorSession stamped with the request workspace's id. app.post( '/assistant/sessions', @@ -128,4 +181,79 @@ export function registerAuthorRoutes( res.status(204).end(); }) ); + + // The card's Apply button (PLAN §8.3): writes exactly the stored card's + // spec through the same `writeProposal` the save tools use - the click + // is the confirmation, so the model's blocking gate does not apply - + // then appends a `uiEvent: save` user message the model reads as + // "[UI event] User applied proposal " next turn. + app.post( + '/assistant/sessions/:id/save', + asyncRoute(async (req, res) => { + const id = sessionIdParam(req, res); + if (id === undefined) return; + const proposalId = proposalIdOf(req.body, res); + if (proposalId === undefined) return; + const session = await deps.sessions.get(id); + if (session === undefined) { + sendError(res, 404, 'Chat not found.', 'NOT_FOUND'); + return; + } + if (deps.runner?.running(id) !== undefined || savesInFlight.has(id)) { + sendError(res, 409, 'Wait for the Author to finish replying, then apply.', 'BUSY'); + return; + } + savesInFlight.add(id); + try { + const identity = identityOfProposal(session.transcript, proposalId); + if (identity === null) { + const failure = unknownProposalFailure('create', proposalId); + sendError(res, 400, failure.userMessage, failure.code); + return; + } + const ws = await deps.workspaceResolver.resolve(session.workspaceId); + if (ws === undefined) { + const failure = agentGoneFailure(identity.kind === 'update' ? identity.agentId : session.workspaceId); + sendSaveFailure(res, failure); + return; + } + const resolved = resolveStoredProposal(session.transcript, identity, proposalId); + if (!resolved.ok) { + sendSaveFailure(res, resolved); + return; + } + const result = await writeProposal(identity, resolved.output, { + sessionId: id, + requestText: requestTextFor(session.transcript, proposalId), + agents: ws.repo, + versions: ws.versions, + sessions: deps.sessions, + bus: ws.events, + }); + if (!result.ok) { + sendSaveFailure(res, result); + return; + } + const message: AuthorUserMessage = { + id: `m_${randomUUID()}`, + role: 'user', + text: '', + at: new Date().toISOString(), + uiEvent: { kind: 'save', proposalId, agentId: result.agentId, version: result.version }, + }; + await deps.sessions.update(id, (s) => ({ ...s, transcript: [...s.transcript, message] })); + const body: AuthorSaveResponse = { + ok: true, + agentId: result.agentId, + version: result.version, + name: result.name, + enabled: result.enabled, + message, + }; + res.status(200).json(body); + } finally { + savesInFlight.delete(id); + } + }) + ); } diff --git a/apps/agent-forge/server/author/tools/__tests__/describeCoverage.test.ts b/apps/agent-forge/server/author/tools/__tests__/describeCoverage.test.ts index e1875aa5..f1391063 100644 --- a/apps/agent-forge/server/author/tools/__tests__/describeCoverage.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/describeCoverage.test.ts @@ -5,6 +5,7 @@ import { buildAuthorTools, type AuthorToolDeps, type CatalogService } from '../i import type { WorkspaceContext } from '../../../workspace/context'; import type { AgentRepo } from '../../../storage/agentRepo'; import type { VersionStore } from '../../../storage/versionStore'; +import { AuthorSessionStore } from '../../sessionStore'; /** * Every field of every Author tool's input schema carries a non-empty @@ -50,6 +51,7 @@ const DEPS: AuthorToolDeps = { docsDir: '/nonexistent-docs', reviewDraft: async () => ({ pass: true }), requirements: async () => [], + sessions: new AuthorSessionStore('/unused-sessions'), }; /** The zod internals the walk reads (zod 3 `_def` fields, structurally typed). */ diff --git a/apps/agent-forge/server/author/tools/__tests__/proposeAgent.test.ts b/apps/agent-forge/server/author/tools/__tests__/proposeAgent.test.ts index 70d7ddd0..e2ac5a10 100644 --- a/apps/agent-forge/server/author/tools/__tests__/proposeAgent.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/proposeAgent.test.ts @@ -11,6 +11,7 @@ import type { AgentRepo } from '../../../storage/agentRepo'; import type { AuthorToolFailure, ProposalOutput } from '../../../../shared/authorWire'; import { proposalTurnState } from '../../proposal/turnState'; import { passReview } from '../../proposal/review'; +import { AuthorSessionStore } from '../../sessionStore'; import { AUTHOR_TOOL_FACTORIES, buildAuthorTools } from '../index'; import type { AuthorDraft } from '../../draft/schema'; @@ -64,6 +65,7 @@ function fakeDeps(over: Partial = {}): AuthorToolDeps { docsDir: '/nonexistent-docs', reviewDraft: passReview, requirements: async () => [], + sessions: new AuthorSessionStore('/unused-sessions'), ...over, }; } diff --git a/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts b/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts index 333598c9..a151cc8d 100644 --- a/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts @@ -12,6 +12,7 @@ import { specHash } from '../../../storage/specHash'; import { applyAuthorPatch } from '../../draft/toSpec'; import { proposalTurnState } from '../../proposal/turnState'; import { passReview } from '../../proposal/review'; +import { AuthorSessionStore } from '../../sessionStore'; import type { AuthorAssistantMessage, AuthorMessage, @@ -80,6 +81,7 @@ function fakeDeps(repo: AgentRepo, over: Partial = {}): AuthorTo docsDir: '/nonexistent-docs', reviewDraft: passReview, requirements: async () => [], + sessions: new AuthorSessionStore('/unused-sessions'), ...over, }; } diff --git a/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts b/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts index 7af90c7e..5103d6d9 100644 --- a/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts @@ -5,6 +5,7 @@ import type { VersionStore } from '../../../storage/versionStore'; import type { WorkspaceContext } from '../../../workspace/context'; import type { AgentMeta, CatalogGuardrail, CatalogKit, CatalogModel, CatalogSkill, CatalogTool, CatalogTriggerType, McpServerSpecV2, TemplateSummary } from '../../../../shared/wireTypes'; import { AUTHOR_TOOL_NAMES } from '../../toolNames'; +import { AuthorSessionStore } from '../../sessionStore'; import type { AuthorTurn } from '../../turnRunner'; import { AUTHOR_TOOL_FACTORIES, buildAuthorTools, type AuthorToolDeps, type CatalogService } from '../index'; import type { AuthorToolFailure } from '../../../../shared/authorWire'; @@ -78,6 +79,7 @@ function fakeCatalog(over: Partial = {}): CatalogService { function fakeDeps(over: Partial = {}): AuthorToolDeps { return { workspace: async () => ({ repo: fakeRepo({}), versions: fakeVersions() }) as WorkspaceContext, + sessions: new AuthorSessionStore('/unused-sessions'), catalog: fakeCatalog(), templates: { list: async () => [] }, probeMcp: async () => ({ ok: true, tools: [] }), @@ -109,15 +111,15 @@ function failure(value: unknown): AuthorToolFailure { } describe('buildAuthorTools', () => { - it('returns the 15 tools sorted by name, every one in AUTHOR_TOOL_NAMES', () => { + it('returns the 17 tools sorted by name, every one in AUTHOR_TOOL_NAMES', () => { const built = buildAuthorTools(TURN, fakeDeps()); const names = built.map(nameOf); - expect(names).toHaveLength(15); + expect(names).toHaveLength(17); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); for (const name of names) { expect(AUTHOR_TOOL_NAMES as readonly string[]).toContain(name); } - expect(AUTHOR_TOOL_FACTORIES).toHaveLength(15); + expect(AUTHOR_TOOL_FACTORIES).toHaveLength(17); }); it('builds the same list for different turns (the tool surface never changes mid-session)', () => { diff --git a/apps/agent-forge/server/author/tools/__tests__/searchDocs.test.ts b/apps/agent-forge/server/author/tools/__tests__/searchDocs.test.ts index 10ec385a..bb9201eb 100644 --- a/apps/agent-forge/server/author/tools/__tests__/searchDocs.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/searchDocs.test.ts @@ -6,6 +6,7 @@ import { buildAuthorTools, type AuthorToolDeps } from '../index'; import type { AuthorTurn } from '../../turnRunner'; import type { AuthorToolFailure } from '../../../../shared/authorWire'; import type { WorkspaceContext } from '../../../workspace/context'; +import { AuthorSessionStore } from '../../sessionStore'; /** * searchDocs + the docsIndex it reads: `__fixtures__/docs` holds two @@ -44,6 +45,7 @@ function deps(docsDir: string): AuthorToolDeps { docsDir, reviewDraft: async () => ({ pass: true }), requirements: async () => [], + sessions: new AuthorSessionStore('/unused-sessions'), }; } diff --git a/apps/agent-forge/server/author/tools/index.ts b/apps/agent-forge/server/author/tools/index.ts index 7c9c970d..fc4210f2 100644 --- a/apps/agent-forge/server/author/tools/index.ts +++ b/apps/agent-forge/server/author/tools/index.ts @@ -29,6 +29,7 @@ import type { import type { McpProbeResult } from '../../routes/mcpProbe'; import type { WorkspaceContext } from '../../workspace/context'; import type { ReviewDraft } from '../proposal/review'; +import type { AuthorSessionStore } from '../sessionStore'; import type { AuthorTurn } from '../turnRunner'; import { readToolFactories } from './readTools'; import { probeMcpServerTool } from './mcpProbeTool'; @@ -36,6 +37,7 @@ import { searchDocsTool } from './searchDocsTool'; import { openPageTool } from './openPageTool'; import { proposeAgentTool } from './proposeAgentTool'; import { proposeAgentUpdateTool } from './proposeAgentUpdateTool'; +import { saveAgentTool, saveAgentUpdateTool } from './saveTools'; /** * A registry-index item that names an MCP server (`type: 'mcp'`, @@ -100,6 +102,12 @@ export interface AuthorToolDeps { * root). Advisory - the propose tools degrade a throwing dep to `[]`. */ requirements: (spec: AgentSpecV2) => Promise; + /** + * The Studio-global Author session store (files under + * `studioPaths().sessions`): the save tools update + * `createdAgentIds` through it after a create lands (AA10b). + */ + sessions: AuthorSessionStore; } /** Builds one tool for one turn - the factories close over `turn` (and `deps`). */ @@ -118,6 +126,8 @@ export const AUTHOR_TOOL_FACTORIES: readonly AuthorToolFactory[] = [ openPageTool, proposeAgentTool, proposeAgentUpdateTool, + saveAgentTool, + saveAgentUpdateTool, ]; /** The call name a `ToolDescriptor` carries (`defineTool`/loader results), else its display name. */ diff --git a/apps/agent-forge/server/author/tools/saveTools.ts b/apps/agent-forge/server/author/tools/saveTools.ts new file mode 100644 index 00000000..888919e5 --- /dev/null +++ b/apps/agent-forge/server/author/tools/saveTools.ts @@ -0,0 +1,89 @@ +/** + * The Author's save tools (PLAN §8.2, AA10b): `saveAgent` writes the + * newest create card, `saveAgentUpdate` the newest update card - both + * exactly as shown, and only after the user confirmed in a later + * message. They share `saveFromCard`: the AA10a blocking gate, the + * stored-card resolution, then the one write path + * (`proposal/save.ts` `writeProposal`) over the session's workspace. + * + * The spec written always comes from the STORED card's output - the + * model's `proposalId` argument only picks which card; a successful save + * marks the identity in `proposalTurnState().saved` so a second save in + * the same turn refuses `ALREADY_SAVED`. + */ +import { defineTool } from 'lousho'; +import { z } from 'zod'; +import type { AuthorToolFailure } from '../../../shared/authorWire'; +import { resolveStoredProposal, saveBlockingError } from '../proposal/gate'; +import { agentGoneFailure } from '../proposal/failures'; +import { requestTextFor, writeProposal, type SaveResult } from '../proposal/save'; +import { identityKey, proposalTurnState, type ProposalIdentity } from '../proposal/turnState'; +import type { AuthorTurn } from '../turnRunner'; +import type { AuthorToolDeps, AuthorToolFactory } from './index'; + +/** The id an `AGENT_GONE` refusal names when the workspace is gone: the update's agent, else the card's name. */ +function goneLabel(identity: ProposalIdentity, specName: string): string { + return identity.kind === 'update' ? identity.agentId : specName; +} + +/** + * The shared execute body both save tools run: gate the save, resolve the + * stored card, resolve the session's workspace (`AGENT_GONE` when it no + * longer resolves - PLAN §19.3), then write through `writeProposal`. + */ +async function saveFromCard( + turn: AuthorTurn, + deps: AuthorToolDeps, + identity: ProposalIdentity, + proposalId: string +): Promise { + const blocked = saveBlockingError(turn, identity); + if (blocked !== null) return blocked; + const resolved = resolveStoredProposal(turn.storedTranscript, identity, proposalId); + if (!resolved.ok) return resolved; + const workspace = await deps.workspace().catch(() => undefined); + if (workspace === undefined) return agentGoneFailure(goneLabel(identity, resolved.output.spec.name)); + const result = await writeProposal(identity, resolved.output, { + sessionId: turn.sessionId, + requestText: requestTextFor(turn.storedTranscript, proposalId), + agents: workspace.repo, + versions: workspace.versions, + sessions: deps.sessions, + bus: workspace.events, + }); + if (result.ok) proposalTurnState(turn).saved.add(identityKey(identity)); + return result; +} + +/** AA10b's save tool for new agents; updates are `saveAgentUpdate`. */ +export const saveAgentTool: AuthorToolFactory = (turn, deps) => + defineTool({ + name: 'saveAgent', + description: + 'Save the newest proposal card for a NEW agent, exactly as shown. Only call it after the user confirmed that card in a later message. The agent is saved disabled.', + input: z.object({ + proposalId: z + .string() + .min(1) + .max(200) + .describe('The proposalId of the card the user confirmed, copied exactly from the proposeAgent result.'), + }), + execute: ({ proposalId }) => saveFromCard(turn, deps, { kind: 'create' }, proposalId), + }); + +/** AA10b's save tool for existing agents; creates are `saveAgent`. */ +export const saveAgentUpdateTool: AuthorToolFactory = (turn, deps) => + defineTool({ + name: 'saveAgentUpdate', + description: + 'Save the newest proposal card for an EXISTING agent, exactly as shown. Only call it after the user confirmed that card in a later message.', + input: z.object({ + agentId: z.string().min(1).max(128).describe('Id of the agent the card changes.'), + proposalId: z + .string() + .min(1) + .max(200) + .describe('The proposalId of the update card the user confirmed, copied exactly from the proposeAgentUpdate result.'), + }), + execute: ({ agentId, proposalId }) => saveFromCard(turn, deps, { kind: 'update', agentId }, proposalId), + }); diff --git a/apps/agent-forge/server/author/wiring.ts b/apps/agent-forge/server/author/wiring.ts index ca5db6b6..fccd9702 100644 --- a/apps/agent-forge/server/author/wiring.ts +++ b/apps/agent-forge/server/author/wiring.ts @@ -95,6 +95,7 @@ export function createAuthorRunner(deps: CreateAuthorRunnerDeps): AuthorTurnRunn probeMcp: (server) => probeMcpServer(server, probeDeps), docsDir: deps.docsDir, reviewDraft: passReview, + sessions: deps.sessions, }; const workspaceForTurn = async (turn: AuthorTurn): Promise => { const session = await deps.sessions.get(turn.sessionId); diff --git a/apps/agent-forge/shared/authorWire.ts b/apps/agent-forge/shared/authorWire.ts index c987f76f..a447fe0b 100644 --- a/apps/agent-forge/shared/authorWire.ts +++ b/apps/agent-forge/shared/authorWire.ts @@ -178,6 +178,22 @@ export type AuthorClientMessage = /** Whether a proposal card creates a new agent or updates an existing one. */ export type ProposalKind = 'create' | 'update'; +/** + * `POST /assistant/sessions/:id/save`'s 200 body (the card's Apply + * button): the saved agent's id, version, name and enabled state, plus + * the `uiEvent` user message the route appended to the transcript - the + * client renders it so its local transcript matches the stored one. + */ +export interface AuthorSaveResponse { + ok: true; + agentId: string; + version: number; + name: string; + enabled: boolean; + /** The user message appended to the transcript (`uiEvent: { kind: 'save', ... }`). */ + message: AuthorUserMessage; +} + /** * The `ok: true` output of `proposeAgent`/`proposeAgentUpdate` - the card * the Author panel renders (PLAN §8.3). `proposalId` is the propose call's diff --git a/apps/agent-forge/src/runtime/runtimeClient.ts b/apps/agent-forge/src/runtime/runtimeClient.ts index fceb2323..2e0ec6a7 100644 --- a/apps/agent-forge/src/runtime/runtimeClient.ts +++ b/apps/agent-forge/src/runtime/runtimeClient.ts @@ -59,6 +59,7 @@ import { STUDIO_TOKEN_HEADER, loadStudioToken } from './studioToken'; import { AUTHOR_STREAM_PATH, type AuthorFocus, + type AuthorSaveResponse, type AuthorSession, type AuthorSessionRecord, type AuthorSessionSummary, @@ -76,15 +77,23 @@ interface RuntimeClientOptions { token?: string; } -/** Builds the error for a non-ok response: the server's `{ error }` message when present, else `fallback`. */ -function apiError(body: { error?: string } | undefined, fallback: string, status: number): RuntimeApiError { - return new RuntimeApiError(body?.error || fallback, status); +/** Builds the error for a non-ok response: the server's `{ error }` message when present, else `fallback`, keeping `code` and `details`. */ +function apiError( + body: { error?: string; code?: string; details?: unknown } | undefined, + fallback: string, + status: number +): RuntimeApiError { + return new RuntimeApiError(body?.error || fallback, status, body?.code, body?.details); } export class RuntimeApiError extends Error { constructor( message: string, - public readonly status: number + public readonly status: number, + /** The server's `{ code }` when it sent one (e.g. 'STALE_PROPOSAL'). */ + public readonly code?: string, + /** The server's `{ details }` payload when it sent one (e.g. `{ latestProposalId }`). */ + public readonly details?: unknown ) { super(message); } @@ -652,6 +661,21 @@ class RuntimeClient implements AgentApi { await this.request(`/assistant/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }); } + /** + * The proposal card's Apply button (`POST /assistant/sessions/:id/save`): + * writes exactly the stored card's spec and answers the save result plus + * the appended `uiEvent` user message. Rejects with a `RuntimeApiError` + * whose `code`/`details` carry the refusal (`STALE_PROPOSAL` + + * `details.latestProposalId`, `ALREADY_SAVED`, `BUSY`, ...). + */ + // fallow-ignore-next-line unused-class-member -- the card's Apply button calls it once AA16 lands + async saveProposal(sessionId: string, proposalId: string): Promise { + return this.request(`/assistant/sessions/${encodeURIComponent(sessionId)}/save`, { + method: 'POST', + body: JSON.stringify({ proposalId }), + }); + } + /** The Author's model choices plus `ready`/`missingKeyFor` (`GET /settings/author`). */ // fallow-ignore-next-line unused-class-member -- the Author panel's setup card calls it once AA15 lands async getAuthorSettings(): Promise {