Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 60 additions & 1 deletion packages/core/src/events.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 } },
],
});
});
});
38 changes: 35 additions & 3 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand All @@ -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;
};
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
AgentFactory,
AgentRuntime,
AgentRegistry,
ContentPart,
CtxRunFn,
CtxRunResult,
RunState,
Expand All @@ -18,6 +19,7 @@ export type {
RunEventType,
RunEventData,
RunEvent,
ResultOutput,
Tool,
McpServer,
} from './types.js';
Expand All @@ -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';
Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 {
Expand Down
Loading