diff --git a/package-lock.json b/package-lock.json index c8ae5b0..1e76a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,16 @@ "packages/*" ], "devDependencies": { - "@anthropic-ai/sdk": "*", - "@types/node": "*", - "@typescript-eslint/eslint-plugin": "*", - "@typescript-eslint/parser": "*", - "@vitest/coverage-v8": "*", - "eslint": "*", - "eslint-config-prettier": "*", - "eslint-plugin-prettier": "*", - "prettier": "*", - "typescript": "*", + "@anthropic-ai/sdk": "latest", + "@types/node": "latest", + "@typescript-eslint/eslint-plugin": "latest", + "@typescript-eslint/parser": "latest", + "@vitest/coverage-v8": "latest", + "eslint": "latest", + "eslint-config-prettier": "latest", + "eslint-plugin-prettier": "latest", + "prettier": "latest", + "typescript": "latest", "vitest": "latest", "zod": "4.3.6", "zod-to-json-schema": "3.25.2" diff --git a/packages/core/src/events.test.ts b/packages/core/src/events.test.ts index 3a770af..f975855 100644 --- a/packages/core/src/events.test.ts +++ b/packages/core/src/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { output, progress, error, result } from './events.js'; +import { output, progress, error, result, parts } from './events.js'; describe('output', () => { it('creates text output event with default format', () => { @@ -91,4 +91,63 @@ describe('result', () => { expect(event.timestamp).toBeGreaterThanOrEqual(before); expect(event.timestamp).toBeLessThanOrEqual(Date.now()); }); + + it('accepts a ContentPart array as output', () => { + const event = result(true, [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ]); + expect(event.data).toEqual({ + type: 'result', + success: true, + output: [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ], + }); + }); +}); + +describe('parts', () => { + it('orders markdown → text → json', () => { + expect(parts({ markdown: '# m', text: 't', json: { j: 1 } })).toEqual([ + { type: 'text/markdown', content: '# m' }, + { type: 'text/plain', content: 't' }, + { type: 'application/json', content: { j: 1 } }, + ]); + }); + + it('omits absent slots', () => { + expect(parts({ markdown: '# m' })).toEqual([{ type: 'text/markdown', content: '# m' }]); + expect(parts({ json: { x: 1 } })).toEqual([{ type: 'application/json', content: { x: 1 } }]); + }); + + it('keeps json: null distinct from absent (null is valid JSON)', () => { + expect(parts({ json: null })).toEqual([{ type: 'application/json', content: null }]); + expect(parts({})).toEqual([]); + }); + + it('appends extra parts after canonical ones', () => { + expect( + parts({ + markdown: '# m', + extra: [{ type: 'text/csv', content: 'a,b\n1,2' }], + }) + ).toEqual([ + { type: 'text/markdown', content: '# m' }, + { type: 'text/csv', content: 'a,b\n1,2' }, + ]); + }); + + it('feeds straight into result()', () => { + const event = result(true, parts({ markdown: '# Done', json: { ok: true } })); + expect(event.data).toEqual({ + type: 'result', + success: true, + output: [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ], + }); + }); }); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 0a3d1ef..351fee6 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,4 +1,4 @@ -import type { RunEvent } from './types.js'; +import type { ContentPart, ResultOutput, RunEvent } from './types.js'; /** Create a text output event */ export const output = (content: unknown, format = 'text'): RunEvent => ({ @@ -21,9 +21,41 @@ export const error = (code: string, message: string, recoverable = false): RunEv timestamp: Date.now(), }); -/** Create a result event */ -export const result = (success: boolean, output?: unknown): RunEvent => ({ +/** + * Create a result event. + * + * `output` may be: + * - A single value (legacy / programmatic shape). + * - An array of `ContentPart` for multi-format results — order signals + * preference. Build via `parts({ markdown, json, text })` or pass the + * array literally. + * + * @example + * yield result(true, parts({ + * markdown: '# Done\n\n42 commits', + * json: { commits: 42 }, + * })); + */ +export const result = (success: boolean, output?: ResultOutput): RunEvent => ({ type: 'result', data: { type: 'result', success, output }, timestamp: Date.now(), }); + +/** + * Build a `ContentPart[]` for `result()`. Order signals render preference: + * markdown → text → json. Pass through `extra` for non-canonical MIME parts. + */ +export const parts = (input: { + markdown?: string; + text?: string; + json?: unknown; + extra?: ContentPart[]; +}): ContentPart[] => { + const list: ContentPart[] = []; + if (input.markdown != null) list.push({ type: 'text/markdown', content: input.markdown }); + if (input.text != null) list.push({ type: 'text/plain', content: input.text }); + if (input.json !== undefined) list.push({ type: 'application/json', content: input.json }); + if (input.extra) list.push(...input.extra); + return list; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91bcbde..c6faefb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export type { AgentFactory, AgentRuntime, AgentRegistry, + ContentPart, CtxRunFn, CtxRunResult, RunState, @@ -18,6 +19,7 @@ export type { RunEventType, RunEventData, RunEvent, + ResultOutput, Tool, McpServer, } from './types.js'; @@ -36,7 +38,7 @@ export { sequence, parallel, map } from './combinators/index.js'; export type { StepRef, MapFactory } from './combinators/index.js'; // Event helpers -export { output, progress, error, result } from './events.js'; +export { output, progress, error, result, parts } from './events.js'; // Builders export { tool } from './tool.js'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 98f5684..aafdf7b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -226,6 +226,38 @@ export interface Run { */ export type RunEventType = 'output' | 'state' | 'error' | 'input_required' | 'result'; +/** + * One MIME-typed slice of a multi-part result. Order in the array signals + * preference — first match by canonical type wins on render. + * + * Canonical types: + * - `text/markdown` — user-facing rendered view + * - `application/json` — structured data for programmatic consumers + * - `text/plain` — raw text fallback + * + * @example + * yield result(true, [ + * { type: 'text/markdown', content: '# Done\\n\\n42 commits' }, + * { type: 'application/json', content: { commits: 42 } }, + * ]); + */ +export interface ContentPart { + type: string; + content: unknown; +} + +/** + * Result output may be: + * - A single value (legacy / programmatic shape) + * - An array of `ContentPart` (multi-format envelope) + * + * The dashboard prefers `text/markdown`, falls back to `application/json`, + * then `text/plain`, then the first part. Children consuming a parent's + * `result.output` (orchestrators, ctx.run callers) should accept either + * shape. + */ +export type ResultOutput = unknown; + /** * Event payloads — discriminated union. * @@ -237,7 +269,7 @@ export type RunEventData = | { type: 'state'; state: RunState; message?: string } | { type: 'error'; code: string; message: string; recoverable: boolean } | { type: 'input_required'; prompt: string; schema?: JsonSchema } - | { type: 'result'; success: boolean; output?: unknown }; + | { type: 'result'; success: boolean; output?: ResultOutput }; /** A single event in the run stream */ export interface RunEvent {