refactor(core): Share preview agent execution across callers (no-changelog) (#39064)

This commit is contained in:
Robin Braumann 2026-09-18 11:32:54 +00:00 committed by GitHub
parent 3d00ed98b0
commit 6f2bc13984
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 729 additions and 445 deletions

View File

@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -11,13 +12,15 @@ import type { AgentChatAttachmentService } from '../agent-chat-attachment.servic
import { AgentChatController } from '../agent-chat.controller';
import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
import type { AgentExecutionService } from '../agent-execution.service';
import type { AgentValidationService } from '../agent-validation.service';
import type { AgentBackgroundJobService } from '../background/agent-background-job.service';
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import type { FlushableResponse } from '../agent-sse-stream';
import type { AgentTestChatService } from '../agent-test-chat.service';
import type { AgentTestRunService } from '../agent-test-run.service';
import { AgentTestRunService } from '../agent-test-run.service';
import type { AgentsService } from '../agents.service';
import type { AgentsBuilderService } from '../builder/agents-builder.service';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import {
expectProjectScopedAgentRoutes,
getRoutesByHandlerName,
@ -28,22 +31,17 @@ function makeController() {
mock<Pick<AgentsService, 'findById' | 'findByProjectId' | 'findByProjectIdPaginated'>>();
const agentExecutionOrchestratorService = mock<AgentExecutionOrchestratorService>();
const agentsBuilderService = mock<AgentsBuilderService>();
const agentTestRunService = mock<AgentTestRunService>();
const agentChatAttachmentService = mock<AgentChatAttachmentService>();
const agentExecutionService = mock<AgentExecutionService>();
const agentValidationService = mock<AgentValidationService>();
const backgroundJobService = mock<AgentBackgroundJobService>();
agentTestRunService.prepareDraftRun.mockResolvedValue({
status: 'ready',
sessionId: 'thread-1',
});
agentTestRunService.streamDraftRun.mockImplementation((config) =>
agentExecutionOrchestratorService.executeForChat({
...config,
memory: {
threadId: config.sessionId,
resourceId: `draft-chat:${config.user.id}`,
},
}),
agentExecutionService.findThreadById.mockResolvedValue(null);
agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
const agentTestRunService = new AgentTestRunService(
agentExecutionService,
agentValidationService,
agentExecutionOrchestratorService,
mock<N8NCheckpointStorage>(),
);
const controller = new AgentChatController(
@ -75,7 +73,10 @@ function makeController() {
};
}
function makeSseResponse(writes: string[]): FlushableResponse {
function makeSseResponse(
writes: string[],
beforeWrite?: (chunk: string) => void,
): FlushableResponse {
const emitter = new EventEmitter();
const res = Object.assign(emitter, {
socket: {
@ -86,16 +87,19 @@ function makeSseResponse(writes: string[]): FlushableResponse {
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn((chunk: string) => {
beforeWrite?.(chunk);
writes.push(String(chunk));
return true;
}),
flush: vi.fn(),
end: vi.fn(() => {
emitter.emit('finish');
}),
end: vi.fn(),
writableEnded: false,
destroyed: false,
});
res.end.mockImplementation(() => {
res.writableEnded = true;
emitter.emit('finish');
});
return res as unknown as FlushableResponse;
}
@ -319,10 +323,37 @@ describe('AgentChatController chat message history', () => {
});
describe('AgentChatController SSE done payload', () => {
const operations = [
{
name: 'new chat',
method: 'executeForChat' as const,
start: async (controller: AgentChatController, res: FlushableResponse) =>
await controller.chat(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ message: 'hi', sessionId: 'thread-1' } as never,
),
done: { type: 'done', sessionId: 'thread-1', executionId: 'exec-99' },
},
{
name: 'resumed chat',
method: 'resumeForChat' as const,
start: async (controller: AgentChatController, res: FlushableResponse) =>
await controller.chatResume(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } } as never,
),
done: { type: 'done', executionId: 'exec-99' },
},
];
it('does not start a chat after the response closes during preparation', async () => {
const { controller, agentTestRunService } = makeController();
const { controller, agentTestRunService, agentExecutionOrchestratorService } = makeController();
let resolvePreparation = (_value: { status: 'ready'; sessionId: string }) => {};
agentTestRunService.prepareDraftRun.mockReturnValue(
const prepareDraftRun = vi.spyOn(agentTestRunService, 'prepareDraftRun').mockReturnValue(
new Promise((resolve) => {
resolvePreparation = resolve;
}),
@ -335,24 +366,118 @@ describe('AgentChatController SSE done payload', () => {
'agent-1',
{ message: 'hi' } as never,
);
await vi.waitFor(() => expect(agentTestRunService.prepareDraftRun).toHaveBeenCalled());
await vi.waitFor(() => expect(prepareDraftRun).toHaveBeenCalled());
(res as unknown as EventEmitter).emit('close');
resolvePreparation({ status: 'ready', sessionId: 'thread-1' });
await request;
expect(agentTestRunService.streamDraftRun).not.toHaveBeenCalled();
expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
});
it('includes executionId on done when recorded', async () => {
it.each(operations)('sends done after the $name settles', async ({ method, start, done }) => {
const { controller, agentExecutionOrchestratorService } = makeController();
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) {
const finalization = createDeferredPromise();
const finalizationStarted = createDeferredPromise();
let receivedSignal: AbortSignal | undefined;
agentExecutionOrchestratorService[method].mockImplementation(async function* (config) {
receivedSignal = config.abortSignal;
yield { type: 'finish', finishReason: 'stop' };
finalizationStarted.resolve();
await finalization.promise;
config.onExecutionRecorded?.('exec-99');
yield* [];
});
const writes: string[] = [];
const res = makeSseResponse(writes);
const request = start(controller, res);
await finalizationStarted.promise;
expect(res.end).not.toHaveBeenCalled();
expect(writes.some((line) => line.includes('"done"'))).toBe(false);
finalization.resolve();
await request;
const events = writes
.filter((line) => line.startsWith('data: '))
.map((line) => JSON.parse(line.slice(6).trim()) as { type: string });
expect(events).toContainEqual(done);
expect(res.end).toHaveBeenCalledOnce();
res.emit('close');
expect(receivedSignal?.aborted).toBe(false);
});
it.each(operations)('does not send done for a suspended $name', async ({ method, start }) => {
const { controller, agentExecutionOrchestratorService } = makeController();
agentExecutionOrchestratorService[method].mockImplementation(async function* () {
yield {
type: 'tool-call-suspended',
runId: 'run-1',
toolCallId: 'tc-1',
toolName: 'ask_questions',
};
});
const writes: string[] = [];
await start(controller, makeSseResponse(writes));
const eventTypes = writes
.filter((line) => line.startsWith('data: '))
.map((line) => (JSON.parse(line.slice(6).trim()) as { type: string }).type);
expect(eventTypes).toContain('tool-call-suspended');
expect(eventTypes).not.toContain('done');
});
it.each(operations)(
'aborts the $name when its SSE response closes early',
async ({ start, method }) => {
const { controller, agentExecutionOrchestratorService } = makeController();
let receivedSignal: AbortSignal | undefined;
let releaseRun = () => {};
let markStarted = () => {};
const runStarted = new Promise<void>((resolve) => {
markStarted = resolve;
});
const runBlocked = new Promise<void>((resolve) => {
releaseRun = resolve;
});
agentExecutionOrchestratorService[method].mockImplementation(async function* (config) {
receivedSignal = (config as { abortSignal?: AbortSignal }).abortSignal;
markStarted();
await runBlocked;
yield* [];
});
const res = makeSseResponse([]);
const request = start(controller, res);
await runStarted;
(res as unknown as EventEmitter).emit('close');
releaseRun();
await request;
expect(receivedSignal?.aborted).toBe(true);
},
);
it('settles execution after SSE delivery fails', async () => {
const { controller, agentExecutionOrchestratorService } = makeController();
const deliveryError = new Error('socket write failed');
let receivedSignal: AbortSignal | undefined;
const lifecycle: string[] = [];
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) {
receivedSignal = config.abortSignal;
try {
yield { type: 'text-delta', id: 'text-1', delta: 'first' };
lifecycle.push('continued');
yield { type: 'text-delta', id: 'text-1', delta: 'second' };
} finally {
lifecycle.push('settled');
}
});
const res = makeSseResponse([], (chunk) => {
if (chunk.includes('"text-delta"')) throw deliveryError;
});
await controller.chat(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
@ -361,94 +486,9 @@ describe('AgentChatController SSE done payload', () => {
{ message: 'hi', sessionId: 'thread-1' } as never,
);
const events = writes
.filter((line) => line.startsWith('data: '))
.map((line) => JSON.parse(line.slice(6).trim()) as { type: string });
expect(events).toContainEqual({
type: 'done',
sessionId: 'thread-1',
executionId: 'exec-99',
});
});
it('includes executionId on resume done when recorded', async () => {
const { controller, agentExecutionOrchestratorService } = makeController();
agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) {
config.onExecutionRecorded?.('exec-resume-1');
yield* [];
});
const writes: string[] = [];
const res = makeSseResponse(writes);
await controller.chatResume(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } } as never,
);
const events = writes
.filter((line) => line.startsWith('data: '))
.map((line) => JSON.parse(line.slice(6).trim()) as { type: string });
expect(events).toContainEqual({
type: 'done',
executionId: 'exec-resume-1',
});
});
it.each([
{
name: 'new chat',
start: async (controller: AgentChatController, res: FlushableResponse) =>
await controller.chat(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ message: 'hi' } as never,
),
method: 'executeForChat' as const,
},
{
name: 'resumed chat',
start: async (controller: AgentChatController, res: FlushableResponse) =>
await controller.chatResume(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } } as never,
),
method: 'resumeForChat' as const,
},
])('aborts the $name when its SSE response closes early', async ({ start, method }) => {
const { controller, agentExecutionOrchestratorService } = makeController();
let receivedSignal: AbortSignal | undefined;
let releaseRun = () => {};
let markStarted = () => {};
const runStarted = new Promise<void>((resolve) => {
markStarted = resolve;
});
const runBlocked = new Promise<void>((resolve) => {
releaseRun = resolve;
});
agentExecutionOrchestratorService[method].mockImplementation(async function* (config) {
receivedSignal = (config as { abortSignal?: AbortSignal }).abortSignal;
markStarted();
await runBlocked;
yield* [];
});
const res = makeSseResponse([]);
const request = start(controller, res);
await runStarted;
(res as unknown as EventEmitter).emit('close');
releaseRun();
await request;
expect(receivedSignal?.aborted).toBe(true);
expect(lifecycle).toEqual(['continued', 'settled']);
expect(res.end).toHaveBeenCalledTimes(1);
});
});
@ -575,6 +615,38 @@ describe('AgentChatController attachment cleanup on failed turns', () => {
expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
});
it('deletes stored attachments when the response closes during upload', async () => {
const { controller, agentExecutionOrchestratorService, agentChatAttachmentService } =
makeController();
let finishUpload = (_value: unknown) => {};
agentChatAttachmentService.storeInbound.mockReturnValue(
new Promise((resolve) => {
finishUpload = resolve;
}),
);
agentChatAttachmentService.deleteByIds.mockResolvedValue(undefined);
const { res } = makeCleanupSseResponse();
const request = controller.chat(
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
res,
'agent-1',
{ message: 'hi', attachments: [textAttachment('notes.txt')] } as never,
);
await vi.waitFor(() => expect(agentChatAttachmentService.storeInbound).toHaveBeenCalled());
(res as unknown as EventEmitter).emit('close');
finishUpload({
id: 'att-1',
fileName: 'notes.txt',
mimeType: 'text/plain',
fileSizeBytes: 5,
});
await request;
expect(agentChatAttachmentService.deleteByIds).toHaveBeenCalledWith(['att-1']);
expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
});
it('rejects an empty attachment with a dedicated error message', async () => {
const { controller, agentChatAttachmentService } = makeController();
const { res, events } = makeCleanupSseResponse();

View File

@ -26,7 +26,9 @@ import { AgentExecutionOrchestratorService } from '../agent-execution-orchestrat
import type { AgentExecutionService } from '../agent-execution.service';
import type { AgentRunTracingService } from '../agent-run-tracing.service';
import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import { AgentTestRunService } from '../agent-test-run.service';
import { AgentTurnExecutionService } from '../agent-turn-execution.service';
import type { AgentValidationService } from '../agent-validation.service';
import type { Agent } from '../entities/agent.entity';
import type { AgentRepository } from '../repositories/agent.repository';
import {
@ -364,6 +366,60 @@ describe('AgentExecutionOrchestratorService', () => {
);
});
it('settles prepared execution after finalization and runtime release', async () => {
const {
service,
executionService,
checkpointStorage,
runtimeCacheService,
runtime,
onExecutionRecorded,
} = makeTurn();
const testRunService = new AgentTestRunService(
executionService,
mock<AgentValidationService>(),
service,
checkpointStorage,
);
const finalization = createDeferredPromise<string>();
executionService.finalizeExecution.mockReturnValue(finalization.promise);
const onChunk = vi.fn();
const input = { agentId, projectId, user, onChunk, onExecutionRecorded };
let settled = false;
const execution = (
operation === 'start'
? testRunService.executePreparedDraftRun({
...input,
message: 'hello',
sessionId: 'thread-1',
})
: testRunService.resumePreparedDraftRun({
...input,
runId: 'run-1',
toolCallId: 'tc-1',
resumeData: { approved: true },
})
).then((result) => {
settled = true;
expect(runtimeCacheService.releaseRuntimeLease).toHaveBeenCalledWith(runtime.agent);
return result;
});
await vi.waitFor(() => expect(executionService.finalizeExecution).toHaveBeenCalled());
expect(onChunk).toHaveBeenCalledWith({ type: 'finish', finishReason: 'stop' });
expect(settled).toBe(false);
expect(onExecutionRecorded).not.toHaveBeenCalled();
expect(runtimeCacheService.releaseRuntimeLease).not.toHaveBeenCalled();
finalization.resolve('finalized-execution');
await expect(execution).resolves.toEqual({
status: 'completed',
response: '',
executionId: 'finalized-execution',
});
expect(onExecutionRecorded).toHaveBeenCalledWith('finalized-execution');
});
it('cancels an early-closed stream and finalizes before callbacks and lease release', async () => {
const {
stream,

View File

@ -3,7 +3,7 @@ import type { AgentSseEvent } from '@n8n/api-types';
import { LoggerProxy } from 'n8n-workflow';
import { EventEmitter } from 'node:events';
import { initSseStream, pumpChunks, type FlushableResponse } from '../agent-sse-stream';
import { emitChunkEvents, initSseStream, type FlushableResponse } from '../agent-sse-stream';
// ---------------------------------------------------------------------------
// Helpers
@ -17,7 +17,9 @@ async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
async function collectEvents(chunks: StreamChunk[]): Promise<AgentSseEvent[]> {
const events: AgentSseEvent[] = [];
await pumpChunks(toAsyncIterable(chunks), (e) => events.push(e));
for await (const chunk of toAsyncIterable(chunks)) {
emitChunkEvents(chunk, (event) => events.push(event));
}
return events;
}
@ -32,6 +34,7 @@ function createResponse() {
flushHeaders: vi.fn(),
write: vi.fn(),
flush: vi.fn(),
end: vi.fn(),
socket,
writableEnded: false,
destroyed: false,
@ -43,7 +46,7 @@ function createResponse() {
async function collectSerializedEvents(chunks: StreamChunk[]): Promise<AgentSseEvent[]> {
const { res } = createResponse();
const { send } = initSseStream(res);
await pumpChunks(toAsyncIterable(chunks), send);
for await (const chunk of toAsyncIterable(chunks)) emitChunkEvents(chunk, send);
const events = vi.mocked(res.write).mock.calls.flatMap(([payload]) => {
if (typeof payload !== 'string' || !payload.startsWith('data: ')) return [];
@ -99,10 +102,25 @@ describe('agent-sse-stream — connection setup', () => {
expect(res.write).not.toHaveBeenCalled();
});
it('closes completed delivery without cancelling execution', () => {
vi.useFakeTimers();
const { res } = createResponse();
const { close, abortSignal } = initSseStream(res);
close();
res.emit('close');
vi.mocked(res.write).mockClear();
vi.advanceTimersByTime(30_000);
expect(abortSignal.aborted).toBe(false);
expect(res.end).toHaveBeenCalledOnce();
expect(res.write).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// stringifyError — tested through pumpChunks / emitChunkEvents
// stringifyError — tested through emitChunkEvents
// ---------------------------------------------------------------------------
vi.mock('n8n-workflow', () => ({
@ -111,7 +129,7 @@ vi.mock('n8n-workflow', () => ({
},
}));
describe('agent-sse-stream — stringifyError (via pumpChunks error chunk)', () => {
describe('agent-sse-stream — stringifyError (via error chunk)', () => {
it('extracts .message from an Error instance', async () => {
const events = await collectEvents([{ type: 'error', error: new Error('something broke') }]);
expect(events).toEqual([{ type: 'error', message: 'something broke' }]);
@ -201,7 +219,7 @@ describe('agent-sse-stream — stream completion', () => {
]);
});
it('completes after the runtime stream closes even when a finish chunk is present', async () => {
it('leaves completion delivery to the caller when it receives a finish chunk', async () => {
const events = await collectEvents([
{ type: 'text-delta', id: 't-1', delta: 'hello' },
{ type: 'text-end', id: 't-1' },
@ -214,7 +232,7 @@ describe('agent-sse-stream — stream completion', () => {
]);
});
it('drains every suspension chunk before reporting that the run paused', async () => {
it('maps every suspension chunk', async () => {
const chunks: StreamChunk[] = [
{
type: 'tool-call-suspended',
@ -232,11 +250,8 @@ describe('agent-sse-stream — stream completion', () => {
},
{ type: 'finish', finishReason: 'other' },
];
const events: AgentSseEvent[] = [];
const events = await collectEvents(chunks);
const suspended = await pumpChunks(toAsyncIterable(chunks), (event) => events.push(event));
expect(suspended).toBe(true);
expect(events).toEqual([
{
type: 'tool-call-suspended',

View File

@ -19,6 +19,13 @@ const agentId = 'agent-1';
const projectId = 'project-1';
const user = mock<User>({ id: 'user-1' });
const credentialProvider = mock<CredentialProvider>();
const preparedDraftRunInput = {
agentId,
projectId,
message: 'Hi',
user,
sessionId: 'session-1',
};
const approvalResumeSchema = (() => {
const schema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
if (schema === null) throw new Error('Failed to generate approval resume schema');
@ -77,6 +84,110 @@ function makeService() {
}
describe('AgentTestRunService', () => {
it('executes a prepared draft run without repeating preparation', async () => {
const { service, agentExecutionOrchestratorService, agentValidationService } = makeService();
const executionError = new Error('streamed execution failed');
const chunks: StreamChunk[] = [
{ type: 'text-delta', id: 'text-1', delta: 'Hello' },
{ type: 'error', error: executionError },
{ type: 'text-delta', id: 'text-1', delta: ' there' },
];
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) {
yield* chunks;
config.onExecutionRecorded?.('execution-1');
});
const onChunk = vi.fn();
await expect(
service.executePreparedDraftRun({
...preparedDraftRunInput,
attachments: [
{ id: 'attachment-1', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 5 },
],
errorMode: 'forward',
onChunk,
}),
).resolves.toEqual({
status: 'completed',
response: 'Hello there',
executionId: 'execution-1',
});
expect(agentValidationService.validateAgentIsRunnable).not.toHaveBeenCalled();
expect(onChunk).toHaveBeenCalledWith({ type: 'error', error: executionError });
expect(onChunk).toHaveBeenCalledTimes(3);
expect(agentExecutionOrchestratorService.executeForChat).toHaveBeenCalledWith(
expect.objectContaining({
memory: { threadId: 'session-1', resourceId: 'draft-chat:user-1' },
attachments: [
{ id: 'attachment-1', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 5 },
],
}),
);
});
it('resumes a prepared draft run with its selected memory scope', async () => {
const { service, agentExecutionOrchestratorService, agentExecutionService } = makeService();
agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) {
yield { type: 'text-delta', id: 'text-1', delta: 'continued' };
config.onExecutionRecorded?.('execution-2');
});
await expect(
service.resumePreparedDraftRun({
agentId,
projectId,
runId: 'run-1',
toolCallId: 'tool-call-1',
resumeData: { approved: true },
user,
expectedMemory: { threadId: 'session-1', resourceId: 'draft-chat:user-1' },
initialResponse: 'Already ',
}),
).resolves.toEqual({
status: 'completed',
response: 'Already continued',
executionId: 'execution-2',
});
expect(agentExecutionService.findThreadById).not.toHaveBeenCalled();
expect(agentExecutionOrchestratorService.resumeForChat).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-1',
toolCallId: 'tool-call-1',
expectedMemory: { threadId: 'session-1', resourceId: 'draft-chat:user-1' },
}),
);
});
it('reports an observer failure after the execution stream settles', async () => {
const { service, agentExecutionOrchestratorService } = makeService();
const abortController = new AbortController();
const deliveryError = new Error('delivery failed');
const produced: string[] = [];
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* () {
try {
yield { type: 'text-delta', id: 'text-1', delta: 'first' };
produced.push('continued');
yield { type: 'text-delta', id: 'text-1', delta: 'second' };
} finally {
produced.push('settled');
}
});
const onChunk = vi.fn(() => {
throw deliveryError;
});
await expect(
service.executePreparedDraftRun({
...preparedDraftRunInput,
onChunk,
abortSignal: abortController.signal,
}),
).rejects.toBe(deliveryError);
expect(onChunk).toHaveBeenCalledTimes(1);
expect(produced).toEqual(['continued', 'settled']);
expect(abortController.signal.aborted).toBe(false);
});
it('runs a draft test and returns its response and execution identifiers', async () => {
const { service, agentExecutionOrchestratorService } = makeService();
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) {
@ -354,22 +465,24 @@ describe('AgentTestRunService', () => {
expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
});
it('propagates a streamed execution error instead of completing the draft run', async () => {
it.each(['headless', 'observed'])('rejects a streamed error for an %s run', async (caller) => {
const { service, agentExecutionOrchestratorService } = makeService();
const executionError = new Error('streamed execution failed');
let settled = false;
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* () {
yield { type: 'error', error: executionError };
yield { type: 'finish', finishReason: 'error' };
try {
yield { type: 'error', error: executionError };
yield { type: 'finish', finishReason: 'error' };
} finally {
settled = true;
}
});
await expect(
service.executeDraftRun({
agentId,
projectId,
message: 'Hi',
user,
credentialProvider,
}),
).rejects.toBe(executionError);
const execution =
caller === 'headless'
? service.executeDraftRun({ ...preparedDraftRunInput, credentialProvider })
: service.executePreparedDraftRun({ ...preparedDraftRunInput, onChunk: vi.fn() });
await expect(execution).rejects.toBe(executionError);
expect(settled).toBe(true);
});
});

View File

@ -2,6 +2,7 @@ import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User, UserRepository } from '@n8n/db';
import type { WorkflowExecuteAfterContext } from '@n8n/decorators';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type { InstanceSettings } from 'n8n-core';
import type { IRun, RelatedAgentRun } from 'n8n-workflow';
import { createRunExecutionData } from 'n8n-workflow';
@ -393,9 +394,14 @@ describe('AgentWorkflowToolResumeService → preview chat', () => {
])('pushes the recorded execution after %s', async (_label, result) => {
const { service, userRepository, agentTestRunService, broadcaster } = setup();
userRepository.findOneBy.mockResolvedValue(mock<User>({ id: 'user-1' }));
agentTestRunService.resumeDraftRun.mockResolvedValue(result);
const execution = createDeferredPromise<typeof result>();
agentTestRunService.resumeDraftRun.mockReturnValue(execution.promise);
await service.resume(previewRun, 'success');
const resume = service.resume(previewRun, 'success');
await vi.waitFor(() => expect(agentTestRunService.resumeDraftRun).toHaveBeenCalled());
expect(broadcaster.notify).not.toHaveBeenCalled();
execution.resolve(result);
await resume;
expect(broadcaster.notify).toHaveBeenCalledWith({
projectId: 'project-1',

View File

@ -6,7 +6,6 @@ import {
AgentChatResumeDto,
MAX_AGENT_CHAT_ATTACHMENT_SIZE_BYTES,
MAX_AGENT_CHAT_ATTACHMENT_SIZE_MB,
N8N_CHAT_INTEGRATION_TYPE,
ViewableMimeTypes,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
@ -29,7 +28,7 @@ import {
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import { AgentExecutionService, threadBelongsTo } from './agent-execution.service';
import { messagesToDto } from './agent-message-mapper';
import { type FlushableResponse, initSseStream, pumpChunks } from './agent-sse-stream';
import { type FlushableResponse, initSseStream } from './agent-sse-stream';
import { AgentTestChatService, chatThreadId } from './agent-test-chat.service';
import { AgentTestRunService } from './agent-test-run.service';
import { AgentsService } from './agents.service';
@ -121,10 +120,7 @@ export class AgentChatController {
req.user,
);
const { send } = initSseStream(res);
const abortController = new AbortController();
const abortOnClose = () => abortController.abort();
res.once('close', abortOnClose);
const { send, onChunk, abortSignal, close } = initSseStream(res);
let executionId: string | undefined;
let storedAttachments: StoredAttachmentRef[] | undefined;
try {
@ -134,7 +130,7 @@ export class AgentChatController {
sessionId,
credentialProvider,
});
if (abortController.signal.aborted) return;
if (abortSignal.aborted) return;
if (prepared.status === 'session_not_found') {
send({ type: 'error', message: 'Session not found' });
return;
@ -157,24 +153,25 @@ export class AgentChatController {
threadId,
resourceId: draftChatMemoryResourceId(req.user.id),
});
abortSignal.throwIfAborted();
const suspended = await pumpChunks(
this.agentTestRunService.streamDraftRun({
agentId,
projectId,
message,
attachments: storedAttachments,
user: req.user,
sessionId: threadId,
previewChat: true,
onExecutionRecorded: (id) => {
executionId = id;
},
abortSignal: abortController.signal,
}),
send,
);
if (!suspended) {
const result = await this.agentTestRunService.executePreparedDraftRun({
agentId,
projectId,
message,
attachments: storedAttachments,
user: req.user,
sessionId: threadId,
previewChat: true,
errorMode: 'forward',
onChunk,
onExecutionRecorded: (id) => {
executionId = id;
},
abortSignal,
});
executionId = result.executionId ?? executionId;
if (result.status === 'completed') {
send({ type: 'done', sessionId: threadId, ...(executionId ? { executionId } : {}) });
}
} catch (error) {
@ -186,13 +183,10 @@ export class AgentChatController {
.deleteByIds(storedAttachments.map((ref) => ref.id))
.catch(() => {});
}
if (!abortController.signal.aborted) {
const errorMessage = error instanceof Error ? error.message : 'Chat failed';
send({ type: 'error', message: errorMessage });
}
const errorMessage = error instanceof Error ? error.message : 'Chat failed';
send({ type: 'error', message: errorMessage });
} finally {
res.off('close', abortOnClose);
res.end();
close();
}
}
@ -206,42 +200,32 @@ export class AgentChatController {
) {
const { projectId } = req.params;
const { runId, toolCallId, resumeData } = payload;
const { send } = initSseStream(res);
const abortController = new AbortController();
const abortOnClose = () => abortController.abort();
res.once('close', abortOnClose);
const { send, onChunk, abortSignal, close } = initSseStream(res);
try {
let executionId: string | undefined;
const suspended = await pumpChunks(
this.agentExecutionOrchestratorService.resumeForChat({
agentId,
projectId,
runId,
toolCallId,
resumeData,
user: req.user,
usePublishedVersion: false,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
previewChat: true,
onExecutionRecorded: (id) => {
executionId = id;
},
abortSignal: abortController.signal,
}),
send,
);
if (!suspended) {
send({ type: 'done', ...(executionId ? { executionId } : {}) });
abortSignal.throwIfAborted();
const result = await this.agentTestRunService.resumePreparedDraftRun({
agentId,
projectId,
runId,
toolCallId,
resumeData,
user: req.user,
previewChat: true,
errorMode: 'forward',
onChunk,
abortSignal,
});
if (result.status === 'completed') {
send({
type: 'done',
...(result.executionId ? { executionId: result.executionId } : {}),
});
}
} catch (error) {
if (!abortController.signal.aborted) {
const errorMessage = error instanceof Error ? error.message : 'Resume failed';
send({ type: 'error', message: errorMessage });
}
const errorMessage = error instanceof Error ? error.message : 'Resume failed';
send({ type: 'error', message: errorMessage });
} finally {
res.off('close', abortOnClose);
res.end();
close();
}
}

View File

@ -13,13 +13,7 @@ export type FlushableResponse = Response & { flush?: () => void };
const SSE_HEARTBEAT_INTERVAL_MS = 30_000;
interface ChunkHandlerCtx {
send: (e: AgentSseEvent) => void;
}
/**
* Set up SSE headers and return a typed `send(event)` helper.
*/
/** Set up preview delivery and request cancellation when the connection closes. */
export function initSseStream(res: FlushableResponse) {
res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@ -43,12 +37,33 @@ export function initSseStream(res: FlushableResponse) {
res.once('finish', stopHeartbeat);
res.once('close', stopHeartbeat);
const abortController = new AbortController();
const abortOnClose = () => abortController.abort();
res.once('close', abortOnClose);
const close = () => {
res.off('close', abortOnClose);
stopHeartbeat();
if (!res.writableEnded && !res.destroyed) res.end();
};
const send = (event: AgentSseEvent) => {
if (abortController.signal.aborted) return;
res.write(`data: ${JSON.stringify(event)}\n\n`);
res.flush?.();
};
return { send };
const onChunk = (chunk: StreamChunk) => {
if (abortController.signal.aborted) return;
try {
emitChunkEvents(chunk, send);
} catch (error) {
abortController.abort();
close();
throw error;
}
};
return { send, onChunk, abortSignal: abortController.signal, close };
}
function toAgentSseMessage(message: AgentMessage): AgentSseMessage | undefined {
@ -111,10 +126,7 @@ function emitTextLikeChunk(
}
}
/**
* SSE-emit a tool-* chunk and fire any matching builder side-effect callback.
* Returns `{ suspended: true }` when the chunk was `tool-call-suspended`.
*/
/** Map tool chunks to SSE events. */
function emitToolChunk(
chunk: Extract<
StreamChunk,
@ -129,10 +141,8 @@ function emitToolChunk(
| 'tool-call-suspended';
}
>,
ctx: ChunkHandlerCtx,
): { suspended: boolean } {
const { send } = ctx;
send: (e: AgentSseEvent) => void,
): void {
switch (chunk.type) {
case 'tool-input-start':
send({
@ -191,34 +201,30 @@ function emitToolChunk(
input: chunk.suspendPayload,
};
send({ type: 'tool-call-suspended', payload });
return { suspended: true };
break;
}
}
return { suspended: false };
}
/**
* Translate a single chunk into one or more SSE events.
*
* Returns `{ suspended: true }` when the chunk was a `tool-call-suspended`
* and `{ suspended: false }` for all other chunks.
*/
function emitChunkEvents(chunk: StreamChunk, ctx: ChunkHandlerCtx): { suspended: boolean } {
export function emitChunkEvents(chunk: StreamChunk, send: (event: AgentSseEvent) => void): void {
switch (chunk.type) {
case 'start-step':
ctx.send({ type: 'start-step' });
return { suspended: false };
send({ type: 'start-step' });
return;
case 'finish-step':
ctx.send({ type: 'finish-step' });
return { suspended: false };
send({ type: 'finish-step' });
return;
case 'text-start':
case 'text-delta':
case 'text-end':
case 'reasoning-start':
case 'reasoning-delta':
case 'reasoning-end':
emitTextLikeChunk(chunk, ctx.send);
return { suspended: false };
emitTextLikeChunk(chunk, send);
return;
case 'tool-input-start':
case 'tool-input-delta':
case 'tool-call':
@ -226,39 +232,40 @@ function emitChunkEvents(chunk: StreamChunk, ctx: ChunkHandlerCtx): { suspended:
case 'tool-execution-end':
case 'tool-result':
case 'tool-call-suspended':
return emitToolChunk(chunk, ctx);
emitToolChunk(chunk, send);
return;
case 'message': {
const sseMessage = toAgentSseMessage(chunk.message);
if (sseMessage) ctx.send({ type: 'message', message: sseMessage });
return { suspended: false };
if (sseMessage) send({ type: 'message', message: sseMessage });
return;
}
case 'subagent-chunk': {
if (chunk.parentToolCallId === undefined) return { suspended: false };
ctx.send({
if (chunk.parentToolCallId === undefined) return;
send({
type: 'subagent-chunk',
parentToolCallId: chunk.parentToolCallId,
taskPath: chunk.taskPath,
chunk: chunk.chunk,
});
return { suspended: false };
return;
}
case 'error': {
const errMsg = stringifyError(chunk.error);
ctx.send({ type: 'error', message: errMsg });
return { suspended: false };
send({ type: 'error', message: errMsg });
return;
}
case 'warning': {
ctx.send({
send({
type: 'warning',
message: chunk.message,
...(chunk.code !== undefined && { code: chunk.code }),
...(chunk.source !== undefined && { source: chunk.source }),
...(chunk.server !== undefined && { server: chunk.server }),
});
return { suspended: false };
return;
}
default:
return { suspended: false };
return;
}
}
@ -302,23 +309,3 @@ function stringifyError(error: unknown): string {
}
return 'Unknown error';
}
/**
* Pump SDK stream chunks through a typed AgentSseEvent stream.
*
* Returns `true` when a suspension was emitted (the run paused), `false`
* otherwise.
*/
export async function pumpChunks(
chunks: AsyncIterable<StreamChunk>,
send: (e: AgentSseEvent) => void,
): Promise<boolean> {
const ctx: ChunkHandlerCtx = { send };
let suspended = false;
for await (const chunk of chunks) {
const result = emitChunkEvents(chunk, ctx);
suspended ||= result.suspended;
}
return suspended;
}

View File

@ -17,8 +17,11 @@ import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { z } from 'zod';
import type { StoredAttachmentRef } from './agent-chat-attachment.service';
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import {
AgentExecutionOrchestratorService,
type ExecuteForChatConfig,
type ResumeForChatConfig,
} from './agent-execution-orchestrator.service';
import { AgentExecutionService, threadBelongsTo } from './agent-execution.service';
import { AgentValidationService } from './agent-validation.service';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
@ -36,40 +39,35 @@ export type PrepareDraftRunResult =
| { status: 'session_not_found' }
| { status: 'agent_misconfigured'; missing: string[] };
interface StreamDraftRunInput {
agentId: string;
projectId: string;
message: string;
user: User;
sessionId: string;
attachments?: StoredAttachmentRef[];
source?: string;
/** Set by the in-app preview chat only — see `ExecuteForChatConfig.previewChat`. */
previewChat?: boolean;
onExecutionRecorded?: (executionId: string) => void;
abortSignal?: AbortSignal;
interface DraftRunConsumptionOptions {
errorMode?: 'throw' | 'forward';
onChunk?: (chunk: StreamChunk) => void;
}
interface ExecuteDraftRunInput extends PrepareDraftRunInput {
message: string;
user: User;
source?: string;
abortSignal?: AbortSignal;
export interface ExecutePreparedDraftRunInput
extends Omit<ExecuteForChatConfig, 'memory'>,
DraftRunConsumptionOptions {
sessionId: string;
}
interface ResumeDraftRunInput {
agentId: string;
projectId: string;
sessionId: string;
runId: string;
toolCallId: string;
resumeData: unknown;
export interface ResumePreparedDraftRunInput
extends Omit<ResumeForChatConfig, 'integrationType' | 'usePublishedVersion'>,
DraftRunConsumptionOptions {
user: User;
initialResponse?: string;
}
type ExecuteDraftRunInput = PrepareDraftRunInput &
Pick<ExecuteForChatConfig, 'message' | 'user' | 'source' | 'abortSignal'>;
interface ResumeDraftRunInput
extends Omit<
ResumeForChatConfig,
'expectedMemory' | 'integrationType' | 'usePublishedVersion' | 'onExecutionRecorded'
> {
sessionId: string;
user: User;
source?: string;
/** Set by the in-app preview chat only — see `ExecuteForChatConfig.previewChat`. */
previewChat?: boolean;
response: string;
abortSignal?: AbortSignal;
}
export const agentTestRunContinuationSchema = z
@ -96,19 +94,14 @@ export interface AgentTestRunApproval extends ApprovalSuspendPayload {
continuation: AgentTestRunContinuation;
}
export type AgentTestRunResult =
| { status: 'completed'; response: string; sessionId: string; executionId?: string }
| {
status: 'suspended';
response: string;
sessionId: string;
executionId?: string;
suspensions: AgentTestRunSuspension[];
}
| { status: 'session_not_found' }
| { status: 'agent_misconfigured'; missing: string[] };
export type PreparedDraftRunResult = {
response: string;
executionId?: string;
} & ({ status: 'completed' } | { status: 'suspended'; suspensions: AgentTestRunSuspension[] });
type CollectedDraftRunResult = Extract<AgentTestRunResult, { status: 'completed' | 'suspended' }>;
export type AgentTestRunResult =
| (PreparedDraftRunResult & { sessionId: string })
| Exclude<PrepareDraftRunResult, { status: 'ready' }>;
const expectedApprovalResumeJsonSchema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
@ -185,80 +178,86 @@ export class AgentTestRunService {
return { status: 'ready', sessionId: sessionId ?? randomUUID() };
}
streamDraftRun({
agentId,
projectId,
message,
user,
async executePreparedDraftRun({
sessionId,
attachments,
source,
previewChat,
errorMode,
onChunk,
onExecutionRecorded,
abortSignal,
}: StreamDraftRunInput): AsyncGenerator<StreamChunk> {
return this.agentExecutionOrchestratorService.executeForChat({
agentId,
projectId,
message,
user,
...execution
}: ExecutePreparedDraftRunInput): Promise<PreparedDraftRunResult> {
let executionId: string | undefined;
const stream = this.agentExecutionOrchestratorService.executeForChat({
...execution,
memory: {
threadId: sessionId,
resourceId: draftChatMemoryResourceId(user.id),
resourceId: draftChatMemoryResourceId(execution.user.id),
},
attachments,
source,
previewChat,
onExecutionRecorded,
abortSignal,
});
}
async executeDraftRun(input: ExecuteDraftRunInput): Promise<AgentTestRunResult> {
const prepared = await this.prepareDraftRun(input);
if (prepared.status !== 'ready') return prepared;
let executionId: string | undefined;
const stream = this.streamDraftRun({
...input,
sessionId: prepared.sessionId,
onExecutionRecorded: (id) => {
executionId = id;
onExecutionRecorded?.(id);
},
});
return await this.collectDraftRun(stream, prepared.sessionId, '', () => executionId);
return await this.collectDraftRun(stream, '', () => executionId, { errorMode, onChunk });
}
async resumeDraftRun(input: ResumeDraftRunInput): Promise<AgentTestRunResult> {
const existing = await this.agentExecutionService.findThreadById(input.sessionId);
async resumePreparedDraftRun({
initialResponse = '',
errorMode,
onChunk,
onExecutionRecorded,
...execution
}: ResumePreparedDraftRunInput): Promise<PreparedDraftRunResult> {
let executionId: string | undefined;
const stream = this.agentExecutionOrchestratorService.resumeForChat({
...execution,
usePublishedVersion: false,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
onExecutionRecorded: (id) => {
executionId = id;
onExecutionRecorded?.(id);
},
});
return await this.collectDraftRun(stream, initialResponse, () => executionId, {
errorMode,
onChunk,
});
}
async executeDraftRun({
credentialProvider,
...input
}: ExecuteDraftRunInput): Promise<AgentTestRunResult> {
const prepared = await this.prepareDraftRun({ ...input, credentialProvider });
if (prepared.status !== 'ready') return prepared;
const result = await this.executePreparedDraftRun({
...input,
sessionId: prepared.sessionId,
});
return { ...result, sessionId: prepared.sessionId };
}
async resumeDraftRun({
sessionId,
response,
...input
}: ResumeDraftRunInput): Promise<AgentTestRunResult> {
const existing = await this.agentExecutionService.findThreadById(sessionId);
if (existing && !threadBelongsTo(existing, input.projectId, input.agentId)) {
return { status: 'session_not_found' };
}
let executionId: string | undefined;
const stream = this.agentExecutionOrchestratorService.resumeForChat({
agentId: input.agentId,
projectId: input.projectId,
runId: input.runId,
toolCallId: input.toolCallId,
resumeData: input.resumeData,
user: input.user,
usePublishedVersion: false,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
const result = await this.resumePreparedDraftRun({
...input,
expectedMemory: {
threadId: input.sessionId,
threadId: sessionId,
resourceId: draftChatMemoryResourceId(input.user.id),
},
source: input.source,
previewChat: input.previewChat,
onExecutionRecorded: (id) => {
executionId = id;
},
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
initialResponse: response,
});
return await this.collectDraftRun(stream, input.sessionId, input.response, () => executionId);
return { ...result, sessionId };
}
async resumeDraftApproval(input: {
@ -344,35 +343,50 @@ export class AgentTestRunService {
private async collectDraftRun(
stream: AsyncIterable<StreamChunk>,
sessionId: string,
initialResponse: string,
getExecutionId: () => string | undefined,
): Promise<CollectedDraftRunResult> {
{ errorMode = 'throw', onChunk }: DraftRunConsumptionOptions,
): Promise<PreparedDraftRunResult> {
let response = initialResponse;
const suspensions: AgentTestRunSuspension[] = [];
let observerFailed = false;
let observerError: unknown;
for await (const chunk of stream) {
if (chunk.type === 'error') {
throw chunk.error;
}
if (chunk.type === 'text-delta') {
response += chunk.delta;
} else if (chunk.type === 'tool-call-suspended') {
suspensions.push({
runId: chunk.runId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
...(chunk.input !== undefined ? { input: chunk.input } : {}),
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
});
try {
for await (const chunk of stream) {
if (!observerFailed && onChunk) {
try {
onChunk(chunk);
} catch (error) {
observerFailed = true;
observerError = error;
}
}
if (chunk.type === 'error' && errorMode === 'throw' && !observerFailed) {
throw chunk.error;
}
if (chunk.type === 'text-delta') {
response += chunk.delta;
} else if (chunk.type === 'tool-call-suspended') {
suspensions.push({
runId: chunk.runId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
...(chunk.input !== undefined ? { input: chunk.input } : {}),
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
});
}
}
} catch (error) {
if (observerFailed) throw observerError;
throw error;
}
if (observerFailed) throw observerError;
const executionId = getExecutionId();
const metadata = {
response,
sessionId,
...(executionId ? { executionId } : {}),
};
return suspensions.length > 0

View File

@ -84,100 +84,137 @@ export class AgentTurnExecutionService {
) {}
async *execute(config: ExecuteTurnConfig): AsyncGenerator<StreamChunk> {
const { agentInstance, toolRegistry, backgroundJobSignal, onExecutionRecorded } = config;
let executionId: string | undefined;
let turn: AgentTurnRequest | undefined;
let receivedFinish = false;
const recorder = this.createRecorder(
toolRegistry,
config.toolRegistry,
() => executionId,
config.context,
backgroundJobSignal,
config.backgroundJobSignal,
);
try {
turn = await config.prepare();
const result =
turn.type === 'start'
? await agentInstance.stream(turn.input, turn.options)
: await agentInstance.resume('stream', turn.resumeData, turn.options);
if (turn.type === 'resume') {
recorder.recordHitlResponse(turn.options.toolCallId, turn.resumeData);
}
// Keep SDK startup independent of execution-history storage.
executionId = await this.tryStartExecution(
{
...turn.recording,
...(backgroundJobSignal
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
: {}),
},
recorder.startedAt,
turn.type === 'resume'
? 'Failed to start resumed agent execution recording'
: 'Failed to start agent execution recording',
);
const attributionTracker = createAttributionTracker(config.mcpServerAttributions);
for await (const value of streamAgentChunks(result.stream)) {
const chunk = config.includeHitlToolDetails
? withApprovalToolDetails(value, toolRegistry)
: value;
recorder.record(chunk);
if (chunk.type === 'finish') receivedFinish = true;
if (turn.type === 'start') {
if (chunk.type === 'tool-call-suspended') {
this.logger.info('Chat: tool-call-suspended chunk received', {
agentId: config.context.agentId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
});
}
if (chunk.type === 'finish' && chunk.finishReason === 'max-iterations') {
for (const chunk of getMaxIterationsChunks()) {
recorder.record(chunk);
yield chunk;
}
}
}
for (const attributionChunk of attributionTracker.observe(chunk)) {
recorder.record(attributionChunk);
yield attributionChunk;
}
yield chunk;
}
const startedTurn = await this.startTurn(turn, config, recorder);
executionId = startedTurn.executionId;
receivedFinish = yield* this.streamTurn(startedTurn.stream, turn, config, recorder);
} catch (error) {
recorder.record({ type: 'error', error });
recorder.record({ type: 'finish', finishReason: 'error' });
throw error;
} finally {
if (turn) {
const record = recorder.getMessageRecord();
const cancelled =
turn.options.abortSignal?.aborted ||
(!receivedFinish && !recorder.suspended && record.error === null);
await this.persistRecordedExecution({
executionId,
onExecutionRecorded,
failureMessage:
turn.type === 'resume'
? 'Failed to record resumed agent execution'
: 'Failed to record agent execution',
params: {
...turn.recording,
record: cancelled ? { ...record, finishReason: 'cancelled', error: null } : record,
hitlStatus: recorder.suspended
? 'suspended'
: turn.type === 'resume'
? 'resumed'
: undefined,
},
});
await this.finalizeTurn(turn, config, recorder, executionId, receivedFinish);
}
await config.onSettled?.(recorder.suspended);
}
}
private async startTurn(
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
): Promise<{ stream: ReadableStream<StreamChunk>; executionId: string | undefined }> {
const result =
turn.type === 'start'
? await config.agentInstance.stream(turn.input, turn.options)
: await config.agentInstance.resume('stream', turn.resumeData, turn.options);
if (turn.type === 'resume') {
recorder.recordHitlResponse(turn.options.toolCallId, turn.resumeData);
}
// Keep SDK startup independent of execution-history storage.
const executionId = await this.tryStartExecution(
{
...turn.recording,
...(config.backgroundJobSignal
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
: {}),
},
recorder.startedAt,
turn.type === 'resume'
? 'Failed to start resumed agent execution recording'
: 'Failed to start agent execution recording',
);
return { stream: result.stream, executionId };
}
private async *streamTurn(
stream: ReadableStream<StreamChunk>,
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
): AsyncGenerator<StreamChunk, boolean> {
let receivedFinish = false;
const attributionTracker = createAttributionTracker(config.mcpServerAttributions);
for await (const value of streamAgentChunks(stream)) {
const chunk = config.includeHitlToolDetails
? withApprovalToolDetails(value, config.toolRegistry)
: value;
recorder.record(chunk);
if (chunk.type === 'finish') receivedFinish = true;
if (turn.type === 'start') {
if (chunk.type === 'tool-call-suspended') {
this.logger.info('Chat: tool-call-suspended chunk received', {
agentId: config.context.agentId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
});
}
if (chunk.type === 'finish' && chunk.finishReason === 'max-iterations') {
for (const maxIterationsChunk of getMaxIterationsChunks()) {
recorder.record(maxIterationsChunk);
yield maxIterationsChunk;
}
}
}
for (const attributionChunk of attributionTracker.observe(chunk)) {
recorder.record(attributionChunk);
yield attributionChunk;
}
yield chunk;
}
return receivedFinish;
}
private async finalizeTurn(
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
executionId: string | undefined,
receivedFinish: boolean,
): Promise<void> {
const record = recorder.getMessageRecord();
const cancelled =
turn.options.abortSignal?.aborted ||
(!receivedFinish && !recorder.suspended && record.error === null);
const hitlStatus = recorder.suspended
? 'suspended'
: turn.type === 'resume'
? 'resumed'
: undefined;
await this.persistRecordedExecution({
executionId,
onExecutionRecorded: config.onExecutionRecorded,
failureMessage:
turn.type === 'resume'
? 'Failed to record resumed agent execution'
: 'Failed to record agent execution',
params: {
...turn.recording,
record: cancelled ? { ...record, finishReason: 'cancelled', error: null } : record,
hitlStatus,
},
});
}
createRecorder(
toolRegistry?: ToolRegistry,
getExecutionId: () => string | undefined = () => undefined,