From f54c26f91a3de6203b880f6db413f460c09d5e36 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 21 May 2026 15:23:12 +0300 Subject: [PATCH 1/3] feat: emit artifact content + proposal_created on the runtime event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `content` to the `artifact` RuntimeStreamEvent variant and a new `proposal_created` variant, so a run's event stream carries what the agent produced, not just metadata. mapCommonBackendEvent maps both from the common SSE/HTTP wire (fail-loud — drops an event with no id); the sanitizer gates `content`/`title` behind includeControlPayloads (redacted by default); trace-bridge maps proposal_created to a state_mutation trace event. This package provides the typed contract + wire mapping. The runtime does not originate these events — content events originate in the agent execution backend and pass through; backend/tool-layer emission is separate. Additive (+157, 0 deletions); 218/218 tests green. --- src/backends.ts | 37 +++++++++++++++++++++ src/sanitize.ts | 12 +++++++ src/trace-bridge.ts | 10 ++++++ src/types.ts | 10 ++++++ tests/runtime.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ tests/trace-bridge.test.ts | 20 +++++++++++ 6 files changed, 157 insertions(+) diff --git a/src/backends.ts b/src/backends.ts index c680f2c..0902cb7 100644 --- a/src/backends.ts +++ b/src/backends.ts @@ -344,6 +344,43 @@ function mapCommonBackendEvent( timestamp: nowIso(), } } + if (type === 'artifact') { + const artifactId = stringValue(data.artifactId) ?? stringValue(data.id) ?? stringValue(record.artifactId) + if (!artifactId) return undefined + return { + type: 'artifact', + task: context.task, + session: context.session, + artifactId, + name: stringValue(data.name) ?? stringValue(record.name), + mimeType: stringValue(data.mimeType) ?? stringValue(record.mimeType), + uri: stringValue(data.uri) ?? stringValue(record.uri), + content: stringValue(data.content) ?? stringValue(data.body) ?? stringValue(record.content), + metadata: + data.metadata && typeof data.metadata === 'object' + ? (data.metadata as Record) + : undefined, + timestamp: nowIso(), + } + } + if (type === 'proposal_created' || type === 'proposal' || type === 'filing') { + const proposalId = + stringValue(data.proposalId) ?? stringValue(data.id) ?? stringValue(record.proposalId) + if (!proposalId) return undefined + const status = stringValue(data.status) ?? stringValue(record.status) + return { + type: 'proposal_created', + task: context.task, + session: context.session, + proposalId, + title: stringValue(data.title) ?? stringValue(record.title) ?? proposalId, + status: + status === 'pending' || status === 'approved' || status === 'rejected' + ? status + : undefined, + timestamp: nowIso(), + } + } if (type === 'result' || type === 'final') { const text = stringValue(data.finalText) ?? stringValue(data.text) ?? stringValue(record.text) return text diff --git a/src/sanitize.ts b/src/sanitize.ts index 77aabfa..7afa700 100644 --- a/src/sanitize.ts +++ b/src/sanitize.ts @@ -257,9 +257,21 @@ export function sanitizeRuntimeStreamEvent( name: event.name, mimeType: event.mimeType, uri: options.includeEvidenceIds ? event.uri : undefined, + content: options.includeControlPayloads ? event.content : undefined, metadata: options.includeMetadata ? event.metadata : undefined, } } + if (event.type === 'proposal_created') { + return { + type: event.type, + ...withTask, + ...withSession, + timestamp: event.timestamp, + proposalId: event.proposalId, + title: options.includeControlPayloads ? event.title : undefined, + status: event.status, + } + } if (event.type === 'final') { return { type: event.type, diff --git a/src/trace-bridge.ts b/src/trace-bridge.ts index 8943803..ee227ac 100644 --- a/src/trace-bridge.ts +++ b/src/trace-bridge.ts @@ -221,6 +221,16 @@ function projectToTraceEvent(event: RuntimeStreamEvent): TraceProjection | undef mimeType: event.mimeType, }, } + case 'proposal_created': + return { + kind: 'state_mutation', + payload: { + phase: 'proposal_created', + proposalId: event.proposalId, + title: event.title, + status: event.status, + }, + } case 'task_end': return { kind: event.status === 'failed' || event.status === 'aborted' ? 'error' : 'log', diff --git a/src/types.ts b/src/types.ts index e90e55b..39e325f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -290,9 +290,19 @@ export type RuntimeStreamEvent = name?: string mimeType?: string uri?: string + content?: string metadata?: Record timestamp?: string } + | { + type: 'proposal_created' + task?: AgentTaskSpec + session?: RuntimeSession + proposalId: string + title: string + status?: 'pending' | 'approved' | 'rejected' + timestamp?: string + } | { type: 'backend_error' task: AgentTaskSpec diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index ef8fbd5..7436a07 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -525,6 +525,43 @@ describe('runAgentTask', () => { expect(events.at(-1)).toMatchObject({ type: 'final', status: 'completed', text: 'hello' }) }) + it('maps wire artifact and proposal events from a streamed backend', async () => { + const backend = createOpenAICompatibleBackend({ + apiKey: 'sk-test', + baseUrl: 'https://router.example/v1', + model: 'model-a', + fetchImpl: async () => + new Response( + 'data: {"type":"artifact","artifactId":"art-9","name":"brief.md","mimeType":"text/markdown","content":"# Brief body"}\n\n' + + 'data: {"type":"proposal_created","proposalId":"prop-9","title":"File amendment","status":"pending"}\n\n' + + 'data: [DONE]\n\n', + { status: 200 }, + ), + }) + const events = await collect( + runAgentTaskStream({ + task: { id: 'wire-task', intent: 'produce a brief', requiredKnowledge: [readyReq] }, + backend, + input: { message: 'go' }, + }), + ) + const artifact = events.find((event) => event.type === 'artifact') + expect(artifact).toMatchObject({ + type: 'artifact', + artifactId: 'art-9', + name: 'brief.md', + mimeType: 'text/markdown', + content: '# Brief body', + }) + const proposal = events.find((event) => event.type === 'proposal_created') + expect(proposal).toMatchObject({ + type: 'proposal_created', + proposalId: 'prop-9', + title: 'File amendment', + status: 'pending', + }) + }) + it('retries a thrown fetch error and succeeds on a later attempt', async () => { let calls = 0 const backend = createOpenAICompatibleBackend({ @@ -679,9 +716,19 @@ describe('runAgentTask', () => { name: 'report.json', mimeType: 'application/json', uri: 's3://internal/secret-bucket/key', + content: 'confidential-deliverable-body', metadata: { customerId: 'cust-99' }, timestamp: '2026-05-10T00:00:00.000Z', } + yield { + type: 'proposal_created', + task: ctx.task, + session: ctx.session, + proposalId: 'p1', + title: 'confidential-proposal-title', + status: 'pending', + timestamp: '2026-05-10T00:00:00.000Z', + } yield { type: 'text_delta', task: ctx.task, @@ -711,10 +758,14 @@ describe('runAgentTask', () => { expect(serialized).not.toContain('secret-bucket') expect(serialized).not.toContain('cust-99') expect(serialized).not.toContain('redact@example.com') + // Produced artifact body and proposal title are payloads — redacted by default. + expect(serialized).not.toContain('confidential-deliverable-body') + expect(serialized).not.toContain('confidential-proposal-title') // The collector still records each event by `type` so consumers can act on the stream. expect(collector.summary().eventCountsByType.tool_call).toBe(1) expect(collector.summary().eventCountsByType.tool_result).toBe(1) expect(collector.summary().eventCountsByType.artifact).toBe(1) + expect(collector.summary().eventCountsByType.proposal_created).toBe(1) expect(collector.summary().finalStatus).toBe('completed') expect(collector.summary().finalText).toBe('hi from agent') }) @@ -744,9 +795,19 @@ describe('runAgentTask', () => { artifactId: 'a1', name: 'r.json', uri: 's3://bucket/key', + content: 'the produced deliverable body', metadata: { customerId: 'cust-1' }, timestamp: '2026-05-10T00:00:00.000Z', } + yield { + type: 'proposal_created', + task: ctx.task, + session: ctx.session, + proposalId: 'prop-1', + title: 'Amend filing X for cust-1', + status: 'pending', + timestamp: '2026-05-10T00:00:00.000Z', + } }, }) const task: AgentTaskSpec = { @@ -763,6 +824,13 @@ describe('runAgentTask', () => { expect(serialized).toContain('pnpm test') expect(serialized).toContain('s3://bucket/key') expect(serialized).toContain('cust-1') + // includeControlPayloads exposes the produced artifact body and proposal title. + expect(serialized).toContain('the produced deliverable body') + expect(serialized).toContain('Amend filing X for cust-1') + expect(collector.summary().eventCountsByType.artifact).toBe(1) + expect(collector.summary().eventCountsByType.proposal_created).toBe(1) + const proposal = collector.events.find((e) => e.type === 'proposal_created') + expect(proposal).toMatchObject({ proposalId: 'prop-1', status: 'pending' }) }) it('createRuntimeStreamEventCollector summary tracks session ids and final reason', async () => { diff --git a/tests/trace-bridge.test.ts b/tests/trace-bridge.test.ts index 7ccb7ae..7dd3688 100644 --- a/tests/trace-bridge.test.ts +++ b/tests/trace-bridge.test.ts @@ -173,6 +173,26 @@ describe('createTraceBridge', () => { expect(traces.map((trace) => trace.eventId)).toEqual(['evt-1', 'evt-2', 'evt-3']) }) + it('maps proposal_created into a state_mutation trace event carrying id, title, status', () => { + const bridge = createTraceBridge({ runId: 'run-prop' }) + const trace = bridge.toTraceEvent({ + type: 'proposal_created', + task, + session, + proposalId: 'prop-1', + title: 'Amend filing X', + status: 'pending', + timestamp: '2026-05-10T00:00:00.000Z', + }) + expect(trace?.kind).toBe('state_mutation') + expect(trace?.payload).toMatchObject({ + phase: 'proposal_created', + proposalId: 'prop-1', + title: 'Amend filing X', + status: 'pending', + }) + }) + it('toAgentEvalTrace() one-shot matches createTraceBridge.toTraceEvent()', () => { const event: RuntimeStreamEvent = { type: 'task_start', From d4f3a7daa527d70210e4496b757999c684c07402 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 21 May 2026 18:07:10 +0300 Subject: [PATCH 2/3] chore: fix biome lint errors blocking CI `biome check src tests examples` ran red on two pre-existing errors: noAssignInExpressions in chat-engine.test.ts (rewrote the indexOf loop as a for(;;) + break, matching the file's existing style) and an unused import in agent.test.ts. No behavior change; 218/218 tests green. --- src/durable/tests/chat-engine.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/durable/tests/chat-engine.test.ts b/src/durable/tests/chat-engine.test.ts index 49ee72a..dc23af8 100644 --- a/src/durable/tests/chat-engine.test.ts +++ b/src/durable/tests/chat-engine.test.ts @@ -34,8 +34,9 @@ async function drain(body: ReadableStream): Promise Date: Thu, 21 May 2026 18:09:14 +0300 Subject: [PATCH 3/3] chore: apply biome formatting to backends.ts biome check --write reflowed the produced-state wire-mapping block to satisfy the linter. No behavior change. --- src/backends.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/backends.ts b/src/backends.ts index 0902cb7..26363b3 100644 --- a/src/backends.ts +++ b/src/backends.ts @@ -345,7 +345,8 @@ function mapCommonBackendEvent( } } if (type === 'artifact') { - const artifactId = stringValue(data.artifactId) ?? stringValue(data.id) ?? stringValue(record.artifactId) + const artifactId = + stringValue(data.artifactId) ?? stringValue(data.id) ?? stringValue(record.artifactId) if (!artifactId) return undefined return { type: 'artifact', @@ -375,9 +376,7 @@ function mapCommonBackendEvent( proposalId, title: stringValue(data.title) ?? stringValue(record.title) ?? proposalId, status: - status === 'pending' || status === 'approved' || status === 'rejected' - ? status - : undefined, + status === 'pending' || status === 'approved' || status === 'rejected' ? status : undefined, timestamp: nowIso(), } }