diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 157f31f9..fa59c9b7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -67,6 +67,7 @@ export type { NamedAgentDispatchRequest, DispatchReceipt, DirectAgentPayload, + DirectAgentToolDeclaration, FluePublicError, AgentWebSocketClientMessage, WorkflowWebSocketClientMessage, diff --git a/packages/runtime/src/runtime/flue-app.ts b/packages/runtime/src/runtime/flue-app.ts index 023933d8..d0735c45 100644 --- a/packages/runtime/src/runtime/flue-app.ts +++ b/packages/runtime/src/runtime/flue-app.ts @@ -18,6 +18,8 @@ import { validateWorkflowRequest, } from '../errors.ts'; import type { AgentDispatchRequest, CreatedAgent, DispatchReceipt, NamedAgentDispatchRequest } from '../types.ts'; +import { enqueueDispatch } from './dispatch.ts'; +import type { DispatchQueue } from './dispatch-queue.ts'; import { type AgentHandler, type CreateContextFn, @@ -27,8 +29,6 @@ import { type StartWorkflowAdmissionFn, type WorkflowHandler, } from './handle-agent.ts'; -import type { DispatchQueue } from './dispatch-queue.ts'; -import { enqueueDispatch } from './dispatch.ts'; import { type HandleRunRouteOptions, handleRunRouteRequest } from './handle-run-routes.ts'; import { generateWorkflowRunId } from './ids.ts'; import type { RunPointer, RunRegistry } from './run-registry.ts'; @@ -37,15 +37,15 @@ import type { RunSubscriberRegistry } from './run-subscribers.ts'; import { AgentInvocationResponseSchema, AgentRouteParamSchema, - WorkflowInvocationQuerySchema, - WorkflowRouteParamSchema, ErrorEnvelopeSchema, RunEventListResponseSchema, RunEventsQuerySchema, RunIdParamSchema, RunRecordSchema, WorkflowAdmissionResponseSchema, + WorkflowInvocationQuerySchema, WorkflowInvocationResponseSchema, + WorkflowRouteParamSchema, } from './schemas.ts'; export interface FlueRuntime { @@ -405,6 +405,19 @@ function agentRouteSpec() { properties: { message: { type: 'string' }, session: { type: 'string', minLength: 1, pattern: '.*\\S.*' }, + tools: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description', 'parameters'], + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string', minLength: 1 }, + parameters: { type: 'object', additionalProperties: true }, + kind: { type: 'string', enum: ['client', 'deferred'] }, + }, + }, + }, }, }, }, diff --git a/packages/runtime/src/runtime/handle-agent.ts b/packages/runtime/src/runtime/handle-agent.ts index 95f5b682..cc556fc9 100644 --- a/packages/runtime/src/runtime/handle-agent.ts +++ b/packages/runtime/src/runtime/handle-agent.ts @@ -2,7 +2,7 @@ import type { FlueContextInternal } from '../client.ts'; import { InvalidRequestError, parseJsonBody, RunEventTooLargeError, RunStoreUnavailableError, toHttpResponse, toPublicError } from '../errors.ts'; -import type { AttachedAgentEvent, AttachedAgentEventCallback, CreatedAgent, DirectAgentPayload, DispatchReceipt, FlueEvent, FlueEventCallback } from '../types.ts'; +import type { AttachedAgentEvent, AttachedAgentEventCallback, CreatedAgent, DirectAgentPayload, DirectAgentToolDeclaration, DispatchReceipt, FlueEvent, FlueEventCallback } from '../types.ts'; import type { DispatchInput, DispatchProcessor } from './dispatch-queue.ts'; import { streamActiveRunEvents } from './handle-run-routes.ts'; import { generateWorkflowRunId } from './ids.ts'; @@ -16,7 +16,7 @@ export type CreatedAgentHandler = CreatedAgent; export type WorkflowHandler = (ctx: FlueContextInternal) => unknown | Promise; interface DirectRequestSession { - processDirectInput(input: { message: string }): PromiseLike; + processDirectInput(input: DirectAgentPayload): PromiseLike; } interface DispatchSession { @@ -105,7 +105,7 @@ export function createDirectAgentHandler(agent: CreatedAgentHandler): AgentHandl if (!isDirectRequestSession(session)) { throw new Error('[flue] Internal session does not support direct input processing.'); } - return session.processDirectInput({ message: payload.message }); + return session.processDirectInput(payload); }; } @@ -114,18 +114,52 @@ function isDirectRequestSession(value: unknown): value is DirectRequestSession { } function parseDirectAgentPayload(payload: unknown): DirectAgentPayload { - const expected = 'Direct agent requests must use JSON object body { "message": string, "session"?: string }.'; + const expected = 'Direct agent requests must use JSON object body { "message": string, "session"?: string, "tools"?: DirectAgentToolDeclaration[] }.'; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new InvalidRequestError({ reason: expected }); } - const value = payload as { message?: unknown; session?: unknown }; + const value = payload as { message?: unknown; session?: unknown; tools?: unknown }; if (typeof value.message !== 'string') { throw new InvalidRequestError({ reason: expected }); } if (value.session !== undefined && (typeof value.session !== 'string' || value.session.trim() === '')) { throw new InvalidRequestError({ reason: 'Direct agent request "session" must be a non-empty string when provided.' }); } - return { message: value.message, session: value.session }; + const tools = parseDirectAgentTools(value.tools); + return tools === undefined + ? { message: value.message, session: value.session } + : { message: value.message, session: value.session, tools }; +} + +function parseDirectAgentTools(rawTools: unknown): DirectAgentToolDeclaration[] | undefined { + if (rawTools === undefined) return undefined; + if (!Array.isArray(rawTools)) { + throw new InvalidRequestError({ reason: 'Direct agent request "tools" must be an array when provided.' }); + } + return rawTools.map((rawTool, index) => { + if (!rawTool || typeof rawTool !== 'object' || Array.isArray(rawTool)) { + throw new InvalidRequestError({ reason: `Direct agent request tools[${index}] must be a tool declaration object.` }); + } + const tool = rawTool as Record; + if (typeof tool.name !== 'string' || tool.name.trim() === '') { + throw new InvalidRequestError({ reason: `Direct agent request tools[${index}].name must be a non-empty string.` }); + } + if (typeof tool.description !== 'string' || tool.description.trim() === '') { + throw new InvalidRequestError({ reason: `Direct agent request tools[${index}].description must be a non-empty string.` }); + } + if (!tool.parameters || typeof tool.parameters !== 'object' || Array.isArray(tool.parameters)) { + throw new InvalidRequestError({ reason: `Direct agent request tools[${index}].parameters must be a JSON Schema object.` }); + } + if (tool.kind !== undefined && tool.kind !== 'client' && tool.kind !== 'deferred') { + throw new InvalidRequestError({ reason: `Direct agent request tools[${index}].kind must be "client" or "deferred" when provided.` }); + } + return { + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, + kind: tool.kind as DirectAgentToolDeclaration['kind'], + }; + }); } /** diff --git a/packages/runtime/src/session.ts b/packages/runtime/src/session.ts index 4bcf6fe0..fc855f00 100644 --- a/packages/runtime/src/session.ts +++ b/packages/runtime/src/session.ts @@ -2,7 +2,6 @@ import type { AgentMessage, AgentTool, AgentToolResult, StreamFn } from '@earendil-works/pi-agent-core'; import { Agent } from '@earendil-works/pi-agent-core'; -import { streamSimple } from '@earendil-works/pi-ai'; import type { AssistantMessage, ImageContent, @@ -11,6 +10,7 @@ import type { ToolResultMessage, UserMessage, } from '@earendil-works/pi-ai'; +import { streamSimple } from '@earendil-works/pi-ai'; import type * as v from 'valibot'; import { abortErrorFor, createCallHandle } from './abort.ts'; import { @@ -42,22 +42,25 @@ import { type ResultToolBundle, ResultUnavailableError, } from './result.ts'; +import type { DispatchInput } from './runtime/dispatch-queue.ts'; import { generateOperationId, generateTurnId } from './runtime/ids.ts'; import { getProviderConfiguration, getRegisteredApiKey } from './runtime/providers.ts'; import { createFlueFs } from './sandbox.ts'; - import type { AgentConfig, AgentProfile, BranchSummaryEntry, CallHandle, CompactionEntry, + DirectAgentPayload, + DirectAgentToolDeclaration, DispatchMessageMetadata, FlueEvent, FlueEventCallback, FlueFs, FlueSession, MessageEntry, + PackagedSkillDirectory, PromptModel, PromptOptions, PromptResponse, @@ -70,14 +73,12 @@ import type { SessionToolFactory, ShellOptions, ShellResult, - PackagedSkillDirectory, - SkillReference, SkillOptions, + SkillReference, TaskOptions, ThinkingLevel, ToolDefinition, } from './types.ts'; -import type { DispatchInput } from './runtime/dispatch-queue.ts'; import { addUsage, emptyUsage, fromProviderUsage } from './usage.ts'; const MAX_TASK_DEPTH = 4; @@ -208,6 +209,23 @@ function resolveResultOption( return options.schema; } +function createDirectAgentTools( + declarations: DirectAgentToolDeclaration[] | undefined, +): ToolDefinition[] | undefined { + if (declarations === undefined) return undefined; + return declarations.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + async execute() { + throw new Error( + `[flue] Direct agent tool "${tool.name}" is declaration-only. ` + + 'Client/deferred tool execution and resume is not implemented for direct-agent payload tools yet.', + ); + }, + })); +} + interface InternalTaskOptions extends TaskOptions { inheritedModel?: string; inheritedThinkingLevel?: ThinkingLevel; @@ -766,12 +784,12 @@ export class Session implements FlueSession { ); } - processDirectInput(input: { message: string }): CallHandle { + processDirectInput(input: DirectAgentPayload): CallHandle { return createCallHandle(undefined, (signal) => this.runOperation('prompt', signal, () => this.runPromptCall({ promptText: input.message, schema: undefined, - tools: undefined, + tools: createDirectAgentTools(input.tools), model: undefined, thinkingLevel: undefined, images: undefined, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c4eb3297..140249e1 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -28,6 +28,20 @@ export interface DispatchReceipt { export interface DirectAgentPayload { message: string; session?: string; + tools?: DirectAgentToolDeclaration[]; +} + +/** + * JSON-serializable tool declaration for a single direct-agent interaction. + * + * These declarations expose caller-scoped tools to the model for the prompt. + * They do not provide durable browser/client resume semantics by themselves. + */ +export interface DirectAgentToolDeclaration { + name: string; + description: string; + parameters: Record; + kind?: 'client' | 'deferred'; } export interface FluePublicError { diff --git a/packages/runtime/test/direct-agent.test.ts b/packages/runtime/test/direct-agent.test.ts index 9b23ee00..99e97530 100644 --- a/packages/runtime/test/direct-agent.test.ts +++ b/packages/runtime/test/direct-agent.test.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { describe, expect, it } from 'vitest'; +import { createAgent } from '../src/agent-definition.ts'; import { flue } from '../src/app.ts'; import { configureFlueRuntime, @@ -11,13 +12,14 @@ import { InMemoryRunStore, InMemorySessionStore, } from '../src/internal.ts'; -import { createAgent } from '../src/agent-definition.ts'; -import type { FlueHarness, FlueSession, SessionData, SessionEnv, SessionStore } from '../src/types.ts'; +import type { DirectAgentToolDeclaration, FlueHarness, FlueSession, SessionData, SessionEnv, SessionStore } from '../src/types.ts'; + +type PromptRecord = { session: string; message: string; tools?: DirectAgentToolDeclaration[] }; describe('direct attached agent delivery', () => { it('routes direct HTTP through init and the default session without dispatch', async () => { const initCalls: string[] = []; - const prompts: Array<{ session: string; message: string }> = []; + const prompts: PromptRecord[] = []; const dispatches: unknown[] = []; const agent = createAgent(({ id, payload }) => { @@ -70,7 +72,7 @@ describe('direct attached agent delivery', () => { }); it('preserves agent JSON bodies after exported route middleware reads them', async () => { - const prompts: Array<{ session: string; message: string }> = []; + const prompts: PromptRecord[] = []; let verifiedBody = ''; configureFlueRuntime({ target: 'node', @@ -118,7 +120,23 @@ describe('direct attached agent delivery', () => { expect(operation?.requestBody?.content?.['application/json']?.schema).toMatchObject({ type: 'object', required: ['message'], - properties: { message: { type: 'string' }, session: { type: 'string', minLength: 1, pattern: '.*\\S.*' } }, + properties: { + message: { type: 'string' }, + session: { type: 'string', minLength: 1, pattern: '.*\\S.*' }, + tools: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description', 'parameters'], + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string', minLength: 1 }, + parameters: { type: 'object', additionalProperties: true }, + kind: { type: 'string', enum: ['client', 'deferred'] }, + }, + }, + }, + }, }); expect(operation?.responses?.['200']?.content?.['application/json']).toBeDefined(); expect(operation?.responses?.['200']?.content?.['text/event-stream']).toBeDefined(); @@ -128,7 +146,7 @@ describe('direct attached agent delivery', () => { }); it('routes direct HTTP to a supplied session', async () => { - const prompts: Array<{ session: string; message: string }> = []; + const prompts: PromptRecord[] = []; configureFlueRuntime({ target: 'node', @@ -191,6 +209,46 @@ describe('direct attached agent delivery', () => { expect(stream).not.toContain('event: run_end'); }); + it('plumbs direct HTTP tool declarations to direct input processing', async () => { + const prompts: PromptRecord[] = []; + const tools: DirectAgentToolDeclaration[] = [{ + name: 'lookup', + description: 'Look up client-visible data.', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + kind: 'client', + }]; + + configureFlueRuntime({ + target: 'node', + manifest: { + agents: [{ name: 'assistant', transports: { http: true }, created: true }], + }, + handlers: { assistant: createDirectAgentHandler(createAgent(() => ({ model: false }))) }, + createContext: createFakeContext(prompts), + runStore: new InMemoryRunStore(), + runRegistry: new InMemoryRunRegistry(), + runSubscribers: createRunSubscriberRegistry(), + }); + + const app = new Hono(); + app.route('/', flue()); + + const res = await app.fetch( + new Request('http://localhost/agents/assistant/inst-1', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'hello', session: 'case:123', tools }), + }), + ); + + expect(res.status).toBe(200); + expect(prompts).toEqual([{ session: 'case:123', message: 'hello', tools }]); + }); + it('streams structured correlated errors for failed attached prompts', async () => { configureFlueRuntime({ target: 'node', @@ -239,9 +297,39 @@ describe('direct attached agent delivery', () => { expect((await res.json()) as unknown).toMatchObject({ error: { type: 'invalid_request' } }); }); + it('rejects invalid direct HTTP tool declarations clearly', async () => { + configureFlueRuntime({ + target: 'node', + manifest: { + agents: [{ name: 'assistant', transports: { http: true }, created: true }], + }, + handlers: { assistant: createDirectAgentHandler(createAgent(() => ({ model: false }))) }, + createContext: createTestContext, + }); + + const app = new Hono(); + app.route('/', flue()); + + const res = await app.fetch( + new Request('http://localhost/agents/assistant/inst-1', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'hello', tools: [{ name: 'lookup', description: 'Lookup' }] }), + }), + ); + + expect(res.status).toBe(400); + expect((await res.json()) as unknown).toMatchObject({ + error: { + type: 'invalid_request', + details: expect.stringContaining('tools[0].parameters'), + }, + }); + }); + it('passes the target instance id into created-agent sandbox factories', async () => { const sandboxCalls: Array<{ id: string; cwd?: string }> = []; - const prompts: Array<{ session: string; message: string }> = []; + const prompts: PromptRecord[] = []; const store = new RecordingSessionStore(); configureFlueRuntime({ @@ -283,7 +371,7 @@ describe('direct attached agent delivery', () => { }); }); -function fakeHarness(prompts: Array<{ session: string; message: string }>): FlueHarness { +function fakeHarness(prompts: PromptRecord[]): FlueHarness { return { name: 'default', session: async (name?: string) => fakeSession(name ?? 'default', prompts), @@ -293,12 +381,12 @@ function fakeHarness(prompts: Array<{ session: string; message: string }>): Flue }; } -function fakeSession(session: string, prompts: Array<{ session: string; message: string }>): FlueSession & { processDirectInput(input: { message: string }): PromiseLike } { +function fakeSession(session: string, prompts: PromptRecord[]): FlueSession & { processDirectInput(input: { message: string; tools?: DirectAgentToolDeclaration[] }): PromiseLike } { return { name: session, prompt: (() => Promise.resolve({ text: '', usage: {}, model: { provider: 'test-provider', id: 'test' } })) as never, - processDirectInput: ({ message }: { message: string }) => { - prompts.push({ session, message }); + processDirectInput: ({ message, tools }: { message: string; tools?: DirectAgentToolDeclaration[] }) => { + prompts.push(tools === undefined ? { session, message } : { session, message, tools }); return Promise.resolve({ text: `reply:${message}`, usage: {}, model: { provider: 'test-provider', id: 'test' } }); }, shell: (() => Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })) as never, @@ -310,7 +398,7 @@ function fakeSession(session: string, prompts: Array<{ session: string; message: }; } -function createFakeContext(prompts: Array<{ session: string; message: string }>) { +function createFakeContext(prompts: PromptRecord[]) { return (id: string, runId: string | undefined, payload: unknown, req: Request) => { const ctx = createTestContext(id, runId, payload, req); ctx.initializeCreatedAgent = async (agent, agentPayload) => { diff --git a/packages/runtime/test/observe-turn-events.test.ts b/packages/runtime/test/observe-turn-events.test.ts index ac2ea8ef..e106d731 100644 --- a/packages/runtime/test/observe-turn-events.test.ts +++ b/packages/runtime/test/observe-turn-events.test.ts @@ -2,8 +2,8 @@ import { fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall, registerFau import { describe, expect, it } from 'vitest'; import { createAgent } from '../src/agent-definition.ts'; import { observe } from '../src/app.ts'; -import { createFlueContext, InMemorySessionStore, type DispatchInput } from '../src/internal.ts'; -import type { FlueEvent, FlueSession, SessionEnv } from '../src/types.ts'; +import { createFlueContext, type DispatchInput, InMemorySessionStore } from '../src/internal.ts'; +import type { DirectAgentToolDeclaration, FlueEvent, FlueSession, SessionEnv } from '../src/types.ts'; function createEnv(): SessionEnv { return { @@ -248,8 +248,14 @@ describe('observe model-turn telemetry', () => { const directEvents: FlueEvent[] = []; const directCtx = createContext(); directCtx.subscribeEvent((event) => { directEvents.push(event); }); - const directSession = await (await directCtx.init(agent)).session() as FlueSession & { processDirectInput(input: { message: string }): PromiseLike }; - await directSession.processDirectInput({ message: 'direct input' }); + const directTools: DirectAgentToolDeclaration[] = [{ + name: 'lookup', + description: 'Look up direct-agent data.', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + kind: 'client', + }]; + const directSession = await (await directCtx.init(agent)).session() as FlueSession & { processDirectInput(input: { message: string; tools?: DirectAgentToolDeclaration[] }): PromiseLike }; + await directSession.processDirectInput({ message: 'direct input', tools: directTools }); const dispatchEvents: FlueEvent[] = []; const dispatchCtx = createContext('dispatch-1'); @@ -272,6 +278,11 @@ describe('observe model-turn telemetry', () => { expect(events.every((event) => event.runId === undefined)).toBe(true); } expect(directEvents.find((event) => event.type === 'turn_request')?.instanceId).toBe('persistent-instance'); + expect(directEvents.find((event): event is Extract => event.type === 'turn_request')?.input.tools?.find((tool) => tool.name === 'lookup')).toMatchObject({ + name: 'lookup', + description: 'Look up direct-agent data.', + parameters: directTools[0]?.parameters, + }); expect(dispatchEvents.find((event) => event.type === 'turn_request')?.dispatchId).toBe('dispatch-1'); } finally { registration.unregister(); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7a92e8b1..4c80ce38 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,5 +1,9 @@ +export type { + CreateFlueClientOptions, + FlueClient, + RequestHeaders, +} from './client.ts'; export { createFlueClient } from './client.ts'; -export { FlueSocketError } from './public/websocket.ts'; export type { AgentSocket, AgentSocketEventContext, @@ -17,36 +21,33 @@ export type { WorkflowSocketEventListener, WorkflowSocketInvokeResult, } from './public/websocket.ts'; +export { FlueSocketError } from './public/websocket.ts'; export type { - CreateFlueClientOptions, - FlueClient, - RequestHeaders, -} from './client.ts'; -export type { + AgentManifestEntry, AgentWebSocketClientMessage, AgentWebSocketServerMessage, AttachedAgentEvent, AttachedAgentStreamError, DirectAgentPayload, + DirectAgentToolDeclaration, FlueEvent, FluePublicError, + ListResponse, + LlmAssistantMessage, + LlmImageContent, + LlmMessage, LlmTextContent, LlmThinkingContent, - LlmImageContent, + LlmTool, LlmToolCall, - LlmUserMessage, - LlmAssistantMessage, LlmToolResultMessage, - LlmMessage, - LlmTool, LlmTurnPurpose, + LlmUserMessage, RunOwner, + RunPointer, + RunRecord, WebSocketErrorMessage, WebSocketServerMessage, WorkflowWebSocketClientMessage, WorkflowWebSocketServerMessage, - RunRecord, - RunPointer, - ListResponse, - AgentManifestEntry, } from './types.ts'; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9126dd1b..ab7001bb 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -42,6 +42,15 @@ export interface DirectAgentPayload { message: string; /** Session name. Defaults to `default`. */ session?: string; + /** JSON-serializable tools scoped to this prompt. */ + tools?: DirectAgentToolDeclaration[]; +} + +export interface DirectAgentToolDeclaration { + name: string; + description: string; + parameters: Record; + kind?: 'client' | 'deferred'; } /** Cursor-paginated list response. */ diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts index 1621de82..7fa28083 100644 --- a/packages/sdk/test/client.test.ts +++ b/packages/sdk/test/client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createFlueClient, type AttachedAgentEvent, type LlmAssistantMessage, type LlmMessage } from '../src/index.ts'; +import { type AttachedAgentEvent, createFlueClient, type LlmAssistantMessage, type LlmMessage } from '../src/index.ts'; import { readSse } from '../src/public/stream.ts'; describe('createFlueClient', () => { @@ -12,14 +12,20 @@ describe('createFlueClient', () => { return Response.json({ result: { ok: true } }); }, }); + const tools = [{ + name: 'lookup', + description: 'Look up client-visible data.', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + kind: 'client' as const, + }]; await expect( - client.agents.invoke('hello', 'inst-1', { mode: 'sync', payload: { message: 'Hello', session: 'chat' } }), + client.agents.invoke('hello', 'inst-1', { mode: 'sync', payload: { message: 'Hello', session: 'chat', tools } }), ).resolves.toEqual({ result: { ok: true } }); expect(seen).toHaveLength(1); expect(new URL(seen[0]?.url ?? '').pathname).toBe('/agents/hello/inst-1'); expect(seen[0]?.method).toBe('POST'); - expect(await seen[0]?.json()).toEqual({ message: 'Hello', session: 'chat' }); + expect(await seen[0]?.json()).toEqual({ message: 'Hello', session: 'chat', tools }); }); it('streams attached agent events without workflow identity', async () => {