From 1bbf7d41effe6cef38940ff7d2b7adc7d8d38479 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 9 Jun 2026 18:17:00 +0800 Subject: [PATCH] test(daemon-e2e): split monolithic recent-daemon-apis scenario into focused scenarios - replace scenario 06 with dedicated 06-09 files for model catalog, session children, pending recovery, and image file prompts\n- add prompt execution coverage for parent and child sessions\n- update README scenario index --- .../daemon-e2e/scenarios/06-model-catalog.ts | 60 +++ .../scenarios/06-recent-daemon-apis.ts | 399 ------------------ .../scenarios/07-session-children.ts | 152 +++++++ .../scenarios/08-pending-recovery.ts | 205 +++++++++ .../scenarios/09-image-file-prompts.ts | 143 +++++++ packages/daemon-e2e/scenarios/README.md | 5 + 6 files changed, 565 insertions(+), 399 deletions(-) create mode 100644 packages/daemon-e2e/scenarios/06-model-catalog.ts delete mode 100644 packages/daemon-e2e/scenarios/06-recent-daemon-apis.ts create mode 100644 packages/daemon-e2e/scenarios/07-session-children.ts create mode 100644 packages/daemon-e2e/scenarios/08-pending-recovery.ts create mode 100644 packages/daemon-e2e/scenarios/09-image-file-prompts.ts diff --git a/packages/daemon-e2e/scenarios/06-model-catalog.ts b/packages/daemon-e2e/scenarios/06-model-catalog.ts new file mode 100644 index 000000000..19ef60bd2 --- /dev/null +++ b/packages/daemon-e2e/scenarios/06-model-catalog.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Scenario 06 — model and provider catalog APIs. + * + * Exercises: + * - GET /auth + * - GET /models + * - GET /providers + * - GET /providers/{id} + * - POST /models/{id}:set_default when the current default appears in /models + */ +import assert from 'node:assert/strict'; + +import { DaemonClient } from '../src/index'; + +const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878'; + +async function main() { + console.log(`▶ daemon at ${DAEMON_URL}`); + const client = new DaemonClient({ baseUrl: DAEMON_URL }); + + try { + const auth = await client.getAuth(); + const models = await client.listModels(); + const providers = await client.listProviders(); + assert.ok(Array.isArray(models.items), 'GET /models returns items[]'); + assert.ok(Array.isArray(providers.items), 'GET /providers returns items[]'); + console.log( + `▶ catalog: models=${models.items.length} providers=${providers.items.length} default=${auth.default_model ?? ''}`, + ); + + const firstProvider = providers.items[0]; + if (firstProvider !== undefined) { + const provider = await client.getProvider(firstProvider.id); + assert.equal(provider.id, firstProvider.id, 'GET /providers/{id} returns the requested provider'); + console.log(`▶ catalog: provider ${provider.id} status=${provider.status}`); + } + + const defaultModel = auth.default_model; + if (defaultModel === null || !models.items.some((item) => item.model === defaultModel)) { + console.log('▶ catalog: set_default skipped because current default is not in /models'); + console.log('✓ 06-model-catalog: catalog reads round-tripped'); + return; + } + + const setDefault = await client.setDefaultModel(defaultModel); + assert.equal(setDefault.default_model, defaultModel); + assert.equal(setDefault.model.model, defaultModel); + console.log(`▶ catalog: POST /models/${defaultModel}:set_default returned current default`); + + console.log('✓ 06-model-catalog: model/provider catalog round-tripped'); + } finally { + await client.close(); + } +} + +main().catch((err) => { + console.error('✗ 06-model-catalog failed:', err); + process.exit(1); +}); diff --git a/packages/daemon-e2e/scenarios/06-recent-daemon-apis.ts b/packages/daemon-e2e/scenarios/06-recent-daemon-apis.ts deleted file mode 100644 index ef4c6fc28..000000000 --- a/packages/daemon-e2e/scenarios/06-recent-daemon-apis.ts +++ /dev/null @@ -1,399 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 06 — recent daemon API coverage. - * - * Exercises the daemon surfaces added in the recent branch history: - * - * - model/provider catalog reads, plus a no-op `:set_default` when the - * current default is present in `/models`; - * - direct child-session creation and direct-child listing; - * - pending approval recovery via `GET /approvals?status=pending`; - * - pending question recovery via `GET /questions?status=pending`; - * - uploaded-file prompt references, including pre-submit error mapping for - * missing/non-image file ids and successful PNG file prompt submission. - * - * Usage: - * DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/06-recent-daemon-apis.ts - * - * Exit codes: - * 0 — pass - * 1 — assertion failure, timeout, or daemon error - */ -import assert from 'node:assert/strict'; - -import { ErrorCode, type QuestionAnswer } from '@moonshot-ai/protocol'; - -import { DaemonClient, EnvelopeError, type AnyFrame } from '../src/index'; - -const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878'; -const PROMPT_TIMEOUT_MS = 120_000; -const SHORT_TIMEOUT_MS = 15_000; -const CANARY = `DAEMON_E2E_RECENT_${process.pid}`; - -const ONE_BY_ONE_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', - 'base64', -); - -interface ApprovalRequestedPayload { - approval_id: string; - session_id: string; - tool_call_id: string; - tool_name: string; - action: string; - created_at: string; - expires_at: string; -} - -interface QuestionRequestedPayload { - question_id: string; - session_id: string; - questions: Array<{ - id: string; - question: string; - options: Array<{ id: string; label: string }>; - }>; - created_at: string; - expires_at: string; -} - -async function main() { - console.log(`▶ daemon at ${DAEMON_URL}`); - const client = new DaemonClient({ baseUrl: DAEMON_URL }); - const sessions: string[] = []; - const files: string[] = []; - - try { - await exerciseCatalog(client); - - const parent = await client.createSession({ - title: 'daemon-e2e recent APIs', - metadata: { cwd: process.cwd(), scenario: '06-recent-daemon-apis' }, - }); - sessions.push(parent.id); - console.log(`▶ parent session ${parent.id} created`); - - await exerciseChildren(client, parent.id, sessions); - - await client.connect(); - await client.subscribe(parent.id); - - await exerciseApprovalRecovery(client, parent.id); - await exerciseQuestionRecovery(client, parent.id); - await exerciseImageFilePrompts(client, parent.id, files); - - console.log('✓ 06-recent-daemon-apis: recent daemon surfaces round-tripped'); - } finally { - for (const fileId of files.toReversed()) { - try { - await client.deleteFile(fileId); - } catch { - // ignore - } - } - for (const sid of sessions.toReversed()) { - try { - await client.deleteSession(sid); - } catch { - // ignore - } - } - await client.close(); - } -} - -async function exerciseCatalog(client: DaemonClient): Promise { - const auth = await client.getAuth(); - const models = await client.listModels(); - const providers = await client.listProviders(); - assert.ok(Array.isArray(models.items), 'GET /models returns items[]'); - assert.ok(Array.isArray(providers.items), 'GET /providers returns items[]'); - console.log( - `▶ catalog: models=${models.items.length} providers=${providers.items.length} default=${auth.default_model ?? ''}`, - ); - - const firstProvider = providers.items[0]; - if (firstProvider !== undefined) { - const provider = await client.getProvider(firstProvider.id); - assert.equal(provider.id, firstProvider.id, 'GET /providers/{id} returns the requested provider'); - console.log(`▶ catalog: provider ${provider.id} status=${provider.status}`); - } - - const defaultModel = auth.default_model; - if (defaultModel === null || !models.items.some((item) => item.model === defaultModel)) { - console.log('▶ catalog: set_default skipped because current default is not in /models'); - return; - } - const setDefault = await client.setDefaultModel(defaultModel); - assert.equal(setDefault.default_model, defaultModel); - assert.equal(setDefault.model.model, defaultModel); - console.log(`▶ catalog: POST /models/${defaultModel}:set_default returned current default`); -} - -async function exerciseChildren( - client: DaemonClient, - parentId: string, - sessions: string[], -): Promise { - const child = await client.createChild(parentId, { - title: 'daemon-e2e child', - metadata: { branch: 'direct-child' }, - }); - sessions.push(child.id); - assert.equal(child.metadata['parent_session_id'], parentId); - assert.equal(child.metadata['child_session_kind'], 'child'); - - const grandchild = await client.createChild(child.id, { - title: 'daemon-e2e grandchild', - metadata: { branch: 'grandchild' }, - }); - sessions.push(grandchild.id); - - const parentChildren = await client.listChildren(parentId, { page_size: 10 }); - assert.ok( - parentChildren.items.some((item) => item.id === child.id), - 'parent children list includes the direct child', - ); - assert.equal( - parentChildren.items.some((item) => item.id === grandchild.id), - false, - 'parent children list omits grandchildren', - ); - - const childChildren = await client.listChildren(child.id, { page_size: 10 }); - assert.ok( - childChildren.items.some((item) => item.id === grandchild.id), - 'child children list includes the grandchild', - ); - console.log( - `▶ children: parent=${parentId} child=${child.id} grandchild=${grandchild.id}`, - ); -} - -async function exerciseApprovalRecovery(client: DaemonClient, sid: string): Promise { - const approvalFramePromise = client.waitForFrame(isApprovalRequestedFor(sid), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - const submit = await client.submitPrompt(sid, { - content: [ - { - type: 'text', - text: `Use the Bash tool to run \`echo ${CANARY}\`, then tell me the exact output you observed.`, - }, - ], - }); - console.log(`▶ approval: prompt ${submit.prompt_id} submitted`); - - const frame = await approvalFramePromise; - const approval = payloadOf(frame); - assert.equal(approval.session_id, sid); - - const pending = await client.listPendingApprovals(sid); - const listed = pending.items.find((item) => item.approval_id === approval.approval_id); - assert.ok(listed, 'pending approvals list includes the requested approval'); - assert.equal(listed.tool_call_id, approval.tool_call_id); - assert.equal(listed.tool_name, approval.tool_name); - console.log( - `▶ approval: pending approval ${approval.approval_id} tool=${approval.tool_name}`, - ); - - const resolved = await client.resolveApproval(sid, approval.approval_id, { - decision: 'approved', - }); - assert.equal(resolved.resolved, true); - - const after = await client.listPendingApprovals(sid); - assert.equal( - after.items.some((item) => item.approval_id === approval.approval_id), - false, - 'resolved approval is removed from pending list', - ); - - const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - console.log(`▶ approval: prompt completed via ${finalFrame.type}`); -} - -async function exerciseQuestionRecovery(client: DaemonClient, sid: string): Promise { - const questionFramePromise = client.waitForFrame(isQuestionRequestedFor(sid), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - const submit = await client.submitPrompt(sid, { - content: [ - { - type: 'text', - text: [ - 'Use the AskUserQuestion tool now.', - 'Ask exactly one question: "Which daemon e2e recovery option should continue?"', - 'Use header "E2E".', - 'Use two options: "Continue (Recommended)" and "Stop".', - 'After I answer, reply with the selected option label.', - ].join(' '), - }, - ], - }); - console.log(`▶ question: prompt ${submit.prompt_id} submitted`); - - const frame = await questionFramePromise; - const question = payloadOf(frame); - assert.equal(question.session_id, sid); - - const pending = await client.listPendingQuestions(sid); - const listed = pending.items.find((item) => item.question_id === question.question_id); - assert.ok(listed, 'pending questions list includes the requested question'); - assert.ok(listed.questions.length > 0, 'pending question carries questions[]'); - console.log( - `▶ question: pending question ${question.question_id} items=${listed.questions.length}`, - ); - - const answers: Record = {}; - for (const item of listed.questions) { - const firstOption = item.options[0]; - assert.ok(firstOption, `question ${item.id} should have at least one option`); - answers[item.id] = { kind: 'single', option_id: firstOption.id }; - } - - const resolved = await client.resolveQuestion(sid, question.question_id, { - answers, - method: 'click', - }); - assert.equal(resolved.resolved, true); - - const after = await client.listPendingQuestions(sid); - assert.equal( - after.items.some((item) => item.question_id === question.question_id), - false, - 'resolved question is removed from pending list', - ); - - const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - console.log(`▶ question: prompt completed via ${finalFrame.type}`); -} - -async function exerciseImageFilePrompts( - client: DaemonClient, - sid: string, - files: string[], -): Promise { - await expectEnvelopeCode( - () => - client.submitPrompt(sid, { - content: [ - { - type: 'image', - source: { kind: 'file', file_id: 'file_missing_daemon_e2e' }, - }, - ], - }), - ErrorCode.FILE_NOT_FOUND, - 'missing prompt image file_id', - ); - - const textFile = await client.uploadFile({ - name: 'not-an-image.txt', - data: 'not an image', - mediaType: 'text/plain', - }); - files.push(textFile.id); - await expectEnvelopeCode( - () => - client.submitPrompt(sid, { - content: [ - { - type: 'image', - source: { kind: 'file', file_id: textFile.id }, - }, - ], - }), - ErrorCode.VALIDATION_FAILED, - 'non-image prompt file_id', - ); - - const png = await client.uploadFile({ - name: 'tiny.png', - data: ONE_BY_ONE_PNG, - mediaType: 'image/png', - }); - files.push(png.id); - assert.equal(png.media_type, 'image/png'); - assert.equal(png.size, ONE_BY_ONE_PNG.length); - - const submit = await client.submitPrompt(sid, { - content: [ - { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, - { type: 'image', source: { kind: 'file', file_id: png.id } }, - ], - }); - assert.ok(submit.prompt_id.length > 0, 'image file prompt returns a prompt_id'); - console.log(`▶ image-file: uploaded ${png.id} and submitted prompt ${submit.prompt_id}`); - - try { - await client.abortPrompt(sid, submit.prompt_id); - console.log(`▶ image-file: prompt ${submit.prompt_id} aborted after submit`); - } catch (error) { - if ( - error instanceof EnvelopeError && - (error.code === ErrorCode.PROMPT_ALREADY_COMPLETED || - error.code === ErrorCode.PROMPT_NOT_FOUND) - ) { - console.log(`▶ image-file: prompt ${submit.prompt_id} was already terminal before abort`); - } else { - throw error; - } - } - await client.waitForSessionStatus(sid, 'idle', { timeoutMs: SHORT_TIMEOUT_MS }); -} - -function isApprovalRequestedFor(sid: string): (frame: AnyFrame) => boolean { - return (frame) => - frame.type === 'event.approval.requested' && - payloadSessionId(frame) === sid; -} - -function isQuestionRequestedFor(sid: string): (frame: AnyFrame) => boolean { - return (frame) => - frame.type === 'event.question.requested' && - payloadSessionId(frame) === sid; -} - -function isPromptCompleted(promptId: string): (frame: AnyFrame) => boolean { - return (frame) => { - if (frame.type !== 'prompt.completed') return false; - const payload = (frame.payload ?? {}) as { prompt_id?: string; promptId?: string }; - return (payload.prompt_id ?? payload.promptId) === promptId; - }; -} - -function payloadSessionId(frame: AnyFrame): string | undefined { - const payload = frame.payload as { session_id?: string } | undefined; - return payload?.session_id; -} - -function payloadOf(frame: AnyFrame): T { - assert.ok(frame.payload !== undefined, `${frame.type} frame should carry payload`); - return frame.payload as T; -} - -async function expectEnvelopeCode( - action: () => Promise, - code: ErrorCode, - label: string, -): Promise { - let caught: unknown; - try { - await action(); - } catch (error) { - caught = error; - } - assert.ok(caught instanceof EnvelopeError, `${label}: expected EnvelopeError`); - assert.equal(caught.code, code, `${label}: expected code ${code}, got ${caught.code}`); - console.log(`▶ image-file: ${label} returned code=${caught.code}`); -} - -main().catch((err) => { - console.error('✗ 06-recent-daemon-apis failed:', err); - process.exit(1); -}); diff --git a/packages/daemon-e2e/scenarios/07-session-children.ts b/packages/daemon-e2e/scenarios/07-session-children.ts new file mode 100644 index 000000000..3ea321219 --- /dev/null +++ b/packages/daemon-e2e/scenarios/07-session-children.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Scenario 07 — direct child sessions. + * + * Exercises: + * - POST /sessions/{id}/children + * - GET /sessions/{id}/children + * - parent sessions can run prompts before child creation + * - child sessions can run prompts + * - direct-child listing omits grandchildren + * - missing parent returns 40401 + */ +import assert from 'node:assert/strict'; + +import { ErrorCode } from '@moonshot-ai/protocol'; + +import { DaemonClient, EnvelopeError } from '../src/index'; + +const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878'; +const PROMPT_TIMEOUT_MS = 120_000; +const PARENT_PROMPT_TOKEN = 'PARENT_SESSION_OK'; +const CHILD_PROMPT_TOKEN = 'CHILD_SESSION_OK'; + +async function main() { + console.log(`▶ daemon at ${DAEMON_URL}`); + const client = new DaemonClient({ baseUrl: DAEMON_URL }); + const sessions: string[] = []; + + try { + const parent = await client.createSession({ + title: 'daemon-e2e session children', + metadata: { cwd: process.cwd(), scenario: '07-session-children' }, + }); + sessions.push(parent.id); + console.log(`▶ children: parent session ${parent.id} created`); + + await client.connect(); + await client.subscribe(parent.id); + await submitPromptAndAssertToken(client, parent.id, PARENT_PROMPT_TOKEN, 'parent'); + + const child = await client.createChild(parent.id, { + title: 'daemon-e2e child', + metadata: { branch: 'direct-child' }, + }); + sessions.push(child.id); + assert.equal(child.metadata['parent_session_id'], parent.id); + assert.equal(child.metadata['child_session_kind'], 'child'); + console.log(`▶ children: child session ${child.id} created`); + + await client.subscribe(child.id); + await submitPromptAndAssertToken(client, child.id, CHILD_PROMPT_TOKEN, 'child'); + + const grandchild = await client.createChild(child.id, { + title: 'daemon-e2e grandchild', + metadata: { branch: 'grandchild' }, + }); + sessions.push(grandchild.id); + console.log(`▶ children: grandchild session ${grandchild.id} created`); + + const parentChildren = await client.listChildren(parent.id, { page_size: 10 }); + assert.ok( + parentChildren.items.some((item) => item.id === child.id), + 'parent children list includes the direct child', + ); + assert.equal( + parentChildren.items.some((item) => item.id === grandchild.id), + false, + 'parent children list omits grandchildren', + ); + + const childChildren = await client.listChildren(child.id, { page_size: 10 }); + assert.ok( + childChildren.items.some((item) => item.id === grandchild.id), + 'child children list includes the grandchild', + ); + + await expectEnvelopeCode( + () => client.listChildren(`sess_missing_children_${process.pid}`, { page_size: 10 }), + ErrorCode.SESSION_NOT_FOUND, + 'GET /sessions/{id}/children missing parent', + ); + + console.log('✓ 07-session-children: child session creation, execution, and listing round-tripped'); + } finally { + for (const sid of sessions.toReversed()) { + try { + await client.deleteSession(sid); + } catch { + // ignore + } + } + await client.close(); + } +} + +async function submitPromptAndAssertToken( + client: DaemonClient, + sid: string, + token: string, + label: 'parent' | 'child', +): Promise { + const prompt = await client.submitAndWait( + sid, + { + content: [ + { + type: 'text', + text: `Reply with the exact token ${token} and nothing else.`, + }, + ], + }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + + const messages = await client.listMessages(sid, { page_size: 100 }); + const assistant = + messages.items.find( + (message) => message.role === 'assistant' && message.prompt_id === prompt.prompt_id, + ) ?? + messages.items.find((message) => message.role === 'assistant'); + assert.ok(assistant, `${label} prompt should produce an assistant message`); + const assistantText = assistant.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(''); + assert.ok( + assistantText.includes(token), + `${label} assistant text should contain ${token}, got ${JSON.stringify(assistantText)}`, + ); + console.log(`▶ children: ${label} prompt ${prompt.prompt_id} completed via ${prompt.finalFrame.type}`); +} + +async function expectEnvelopeCode( + action: () => Promise, + code: ErrorCode, + label: string, +): Promise { + let caught: unknown; + try { + await action(); + } catch (error) { + caught = error; + } + assert.ok(caught instanceof EnvelopeError, `${label}: expected EnvelopeError`); + assert.equal(caught.code, code, `${label}: expected code ${code}, got ${caught.code}`); + console.log(`▶ children: ${label} returned code=${caught.code}`); +} + +main().catch((err) => { + console.error('✗ 07-session-children failed:', err); + process.exit(1); +}); diff --git a/packages/daemon-e2e/scenarios/08-pending-recovery.ts b/packages/daemon-e2e/scenarios/08-pending-recovery.ts new file mode 100644 index 000000000..2b0de7eb7 --- /dev/null +++ b/packages/daemon-e2e/scenarios/08-pending-recovery.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * Scenario 08 — pending reverse-RPC recovery APIs. + * + * Exercises: + * - GET /sessions/{id}/approvals?status=pending + * - POST /sessions/{id}/approvals/{approval_id} + * - GET /sessions/{id}/questions?status=pending + * - POST /sessions/{id}/questions/{question_id} + */ +import assert from 'node:assert/strict'; + +import type { QuestionAnswer } from '@moonshot-ai/protocol'; + +import { DaemonClient, type AnyFrame } from '../src/index'; + +const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878'; +const PROMPT_TIMEOUT_MS = 120_000; +const CANARY = `DAEMON_E2E_PENDING_${process.pid}`; + +interface ApprovalRequestedPayload { + approval_id: string; + session_id: string; + tool_call_id: string; + tool_name: string; + action: string; + created_at: string; + expires_at: string; +} + +interface QuestionRequestedPayload { + question_id: string; + session_id: string; + questions: Array<{ + id: string; + question: string; + options: Array<{ id: string; label: string }>; + }>; + created_at: string; + expires_at: string; +} + +async function main() { + console.log(`▶ daemon at ${DAEMON_URL}`); + const client = new DaemonClient({ baseUrl: DAEMON_URL }); + let sid: string | undefined; + + try { + const session = await client.createSession({ + title: 'daemon-e2e pending recovery', + metadata: { cwd: process.cwd(), scenario: '08-pending-recovery' }, + }); + sid = session.id; + console.log(`▶ pending: session ${sid} created`); + + await client.connect(); + await client.subscribe(sid); + + await exerciseApprovalRecovery(client, sid); + await exerciseQuestionRecovery(client, sid); + + console.log('✓ 08-pending-recovery: pending approvals and questions round-tripped'); + } finally { + try { + if (sid) await client.deleteSession(sid); + } catch { + // ignore + } + await client.close(); + } +} + +async function exerciseApprovalRecovery(client: DaemonClient, sid: string): Promise { + const approvalFramePromise = client.waitForFrame(isApprovalRequestedFor(sid), { + timeoutMs: PROMPT_TIMEOUT_MS, + }); + const submit = await client.submitPrompt(sid, { + content: [ + { + type: 'text', + text: `Use the Bash tool to run \`echo ${CANARY}\`, then tell me the exact output you observed.`, + }, + ], + }); + console.log(`▶ approval: prompt ${submit.prompt_id} submitted`); + + const frame = await approvalFramePromise; + const approval = payloadOf(frame); + assert.equal(approval.session_id, sid); + + const pending = await client.listPendingApprovals(sid); + const listed = pending.items.find((item) => item.approval_id === approval.approval_id); + assert.ok(listed, 'pending approvals list includes the requested approval'); + assert.equal(listed.tool_call_id, approval.tool_call_id); + assert.equal(listed.tool_name, approval.tool_name); + console.log(`▶ approval: pending approval ${approval.approval_id} tool=${approval.tool_name}`); + + const resolved = await client.resolveApproval(sid, approval.approval_id, { + decision: 'approved', + }); + assert.equal(resolved.resolved, true); + + const after = await client.listPendingApprovals(sid); + assert.equal( + after.items.some((item) => item.approval_id === approval.approval_id), + false, + 'resolved approval is removed from pending list', + ); + + const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { + timeoutMs: PROMPT_TIMEOUT_MS, + }); + console.log(`▶ approval: prompt completed via ${finalFrame.type}`); +} + +async function exerciseQuestionRecovery(client: DaemonClient, sid: string): Promise { + const questionFramePromise = client.waitForFrame(isQuestionRequestedFor(sid), { + timeoutMs: PROMPT_TIMEOUT_MS, + }); + const submit = await client.submitPrompt(sid, { + content: [ + { + type: 'text', + text: [ + 'Use the AskUserQuestion tool now.', + 'Ask exactly one question: "Which daemon e2e recovery option should continue?"', + 'Use header "E2E".', + 'Use two options: "Continue (Recommended)" and "Stop".', + 'After I answer, reply with the selected option label.', + ].join(' '), + }, + ], + }); + console.log(`▶ question: prompt ${submit.prompt_id} submitted`); + + const frame = await questionFramePromise; + const question = payloadOf(frame); + assert.equal(question.session_id, sid); + + const pending = await client.listPendingQuestions(sid); + const listed = pending.items.find((item) => item.question_id === question.question_id); + assert.ok(listed, 'pending questions list includes the requested question'); + assert.ok(listed.questions.length > 0, 'pending question carries questions[]'); + console.log(`▶ question: pending question ${question.question_id} items=${listed.questions.length}`); + + const answers: Record = {}; + for (const item of listed.questions) { + const firstOption = item.options[0]; + assert.ok(firstOption, `question ${item.id} should have at least one option`); + answers[item.id] = { kind: 'single', option_id: firstOption.id }; + } + + const resolved = await client.resolveQuestion(sid, question.question_id, { + answers, + method: 'click', + }); + assert.equal(resolved.resolved, true); + + const after = await client.listPendingQuestions(sid); + assert.equal( + after.items.some((item) => item.question_id === question.question_id), + false, + 'resolved question is removed from pending list', + ); + + const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { + timeoutMs: PROMPT_TIMEOUT_MS, + }); + console.log(`▶ question: prompt completed via ${finalFrame.type}`); +} + +function isApprovalRequestedFor(sid: string): (frame: AnyFrame) => boolean { + return (frame) => + frame.type === 'event.approval.requested' && + payloadSessionId(frame) === sid; +} + +function isQuestionRequestedFor(sid: string): (frame: AnyFrame) => boolean { + return (frame) => + frame.type === 'event.question.requested' && + payloadSessionId(frame) === sid; +} + +function isPromptCompleted(promptId: string): (frame: AnyFrame) => boolean { + return (frame) => { + if (frame.type !== 'prompt.completed') return false; + const payload = (frame.payload ?? {}) as { prompt_id?: string; promptId?: string }; + return (payload.prompt_id ?? payload.promptId) === promptId; + }; +} + +function payloadSessionId(frame: AnyFrame): string | undefined { + const payload = frame.payload as { session_id?: string } | undefined; + return payload?.session_id; +} + +function payloadOf(frame: AnyFrame): T { + assert.ok(frame.payload !== undefined, `${frame.type} frame should carry payload`); + return frame.payload as T; +} + +main().catch((err) => { + console.error('✗ 08-pending-recovery failed:', err); + process.exit(1); +}); diff --git a/packages/daemon-e2e/scenarios/09-image-file-prompts.ts b/packages/daemon-e2e/scenarios/09-image-file-prompts.ts new file mode 100644 index 000000000..837e12739 --- /dev/null +++ b/packages/daemon-e2e/scenarios/09-image-file-prompts.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * Scenario 09 — uploaded image files in prompt content. + * + * Exercises: + * - missing prompt image file_id returns FILE_NOT_FOUND + * - non-image uploaded file used as image content returns VALIDATION_FAILED + * - uploaded PNG can be referenced by a prompt submission + */ +import assert from 'node:assert/strict'; + +import { ErrorCode } from '@moonshot-ai/protocol'; + +import { DaemonClient, EnvelopeError } from '../src/index'; + +const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878'; +const SHORT_TIMEOUT_MS = 15_000; + +const ONE_BY_ONE_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +async function main() { + console.log(`▶ daemon at ${DAEMON_URL}`); + const client = new DaemonClient({ baseUrl: DAEMON_URL }); + const files: string[] = []; + let sid: string | undefined; + + try { + const session = await client.createSession({ + title: 'daemon-e2e image file prompts', + metadata: { cwd: process.cwd(), scenario: '09-image-file-prompts' }, + }); + sid = session.id; + console.log(`▶ image-file: session ${sid} created`); + + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [ + { + type: 'image', + source: { kind: 'file', file_id: 'file_missing_daemon_e2e' }, + }, + ], + }), + ErrorCode.FILE_NOT_FOUND, + 'missing prompt image file_id', + ); + + const textFile = await client.uploadFile({ + name: 'not-an-image.txt', + data: 'not an image', + mediaType: 'text/plain', + }); + files.push(textFile.id); + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [ + { + type: 'image', + source: { kind: 'file', file_id: textFile.id }, + }, + ], + }), + ErrorCode.VALIDATION_FAILED, + 'non-image prompt file_id', + ); + + const png = await client.uploadFile({ + name: 'tiny.png', + data: ONE_BY_ONE_PNG, + mediaType: 'image/png', + }); + files.push(png.id); + assert.equal(png.media_type, 'image/png'); + assert.equal(png.size, ONE_BY_ONE_PNG.length); + + const submit = await client.submitPrompt(sid, { + content: [ + { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, + { type: 'image', source: { kind: 'file', file_id: png.id } }, + ], + }); + assert.ok(submit.prompt_id.length > 0, 'image file prompt returns a prompt_id'); + console.log(`▶ image-file: uploaded ${png.id} and submitted prompt ${submit.prompt_id}`); + + try { + await client.abortPrompt(sid, submit.prompt_id); + console.log(`▶ image-file: prompt ${submit.prompt_id} aborted after submit`); + } catch (error) { + if ( + error instanceof EnvelopeError && + (error.code === ErrorCode.PROMPT_ALREADY_COMPLETED || + error.code === ErrorCode.PROMPT_NOT_FOUND) + ) { + console.log(`▶ image-file: prompt ${submit.prompt_id} was already terminal before abort`); + } else { + throw error; + } + } + await client.waitForSessionStatus(sid, 'idle', { timeoutMs: SHORT_TIMEOUT_MS }); + + console.log('✓ 09-image-file-prompts: image file prompt references round-tripped'); + } finally { + for (const fileId of files.toReversed()) { + try { + await client.deleteFile(fileId); + } catch { + // ignore + } + } + try { + if (sid) await client.deleteSession(sid); + } catch { + // ignore + } + await client.close(); + } +} + +async function expectEnvelopeCode( + action: () => Promise, + code: ErrorCode, + label: string, +): Promise { + let caught: unknown; + try { + await action(); + } catch (error) { + caught = error; + } + assert.ok(caught instanceof EnvelopeError, `${label}: expected EnvelopeError`); + assert.equal(caught.code, code, `${label}: expected code ${code}, got ${caught.code}`); + console.log(`▶ image-file: ${label} returned code=${caught.code}`); +} + +main().catch((err) => { + console.error('✗ 09-image-file-prompts failed:', err); + process.exit(1); +}); diff --git a/packages/daemon-e2e/scenarios/README.md b/packages/daemon-e2e/scenarios/README.md index 5c04d515f..ca2f02c06 100644 --- a/packages/daemon-e2e/scenarios/README.md +++ b/packages/daemon-e2e/scenarios/README.md @@ -35,7 +35,12 @@ somewhere else. | `01-create-and-send.ts` | Happy path: create session → submit prompt → assert assistant replied with the expected token. | | `02-tool-call-with-approval.ts` | Drives a Bash tool call; built-in approval handler auto-approves; asserts canary round-trips through `tool_result` AND assistant text; asserts session ends in `idle`. | | `03-refresh-replay.ts` | "User refreshes the browser" worst case: Phase 0 probes (`/healthz`, `/meta`, `/auth`) → WS handshake → prompt to populate the ring buffer → fresh WS with `last_seq_by_session` (caught-up first, then `0` for full replay) → REST snapshot → steady-state follow-up prompt. Asserts replay ordering (seq 1..N) and that no `resync_required` fires while the buffer covers the gap. | +| `04-stateless-controls.ts` | Per-request stateless prompt controls diff-dispatch into the session shadow, including plan mode and permission mode transitions. | | `05-workspace.ts` | Workspace registry + folder picker happy path: `fs:home` → `fs:browse $HOME` → `POST /workspaces { root }` → `POST /sessions { workspace_id }` → `GET /sessions?workspace_id=` → prompt round-trip (skipped without provider auth) → `DELETE /workspaces/{id}` (verifies the session survives). | +| `06-model-catalog.ts` | Model/provider catalog reads and current-default `:set_default` round-trip. | +| `07-session-children.ts` | Direct child session creation, child prompt execution, direct-child listing, and missing-parent `40401`. | +| `08-pending-recovery.ts` | Pending approval and pending question recovery APIs, including resolve and pending-list cleanup. | +| `09-image-file-prompts.ts` | Uploaded file prompt references: missing file, non-image validation, and PNG prompt submission. | ## Writing a new scenario