From 2a7be2542006b7bbd058a4d8cfc45a6dfd5df221 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Thu, 8 Oct 2026 11:50:33 +0300 Subject: [PATCH] fix(core): Keep Agent builder sessions out of system agent session routes (no-changelog) - findOwnedById returns top-level sessions only, so the /agents/system routes cannot open, change or post into a builder session. - createThread refuses the id of a child session. - Builder recording is best-effort: a failed admission runs the turn without a record, and a failed finalize does not change the turn result. - Document that future retention must delete through deleteThread. - Add tests for user deletion: builder sessions share the fate of their parent. Co-Authored-By: Claude Opus 5.5 --- .../agents-builder.service.session.test.ts | 155 ++++++++++++++++++ .../agents/builder/agents-builder.service.ts | 108 +++++++++--- .../agent-execution-thread.repository.ts | 22 ++- .../system-agent-execution.service.test.ts | 35 ++++ .../system-agent-execution.service.ts | 4 +- .../agent-builder-execution-records.test.ts | 139 +++++++++++++++- .../agent-thread-repository-queries.test.ts | 7 + .../system-agent-builder-thread.api.test.ts | 78 +++++++++ 8 files changed, 516 insertions(+), 32 deletions(-) create mode 100644 packages/cli/test/integration/agents/system-agent-builder-thread.api.test.ts diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts index 806e3c55b7b..8d89f2d1388 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts @@ -10,6 +10,7 @@ import type { NodeCatalogService } from '@/node-catalog'; import type { InstanceAiCreditService } from '../../../instance-ai/instance-ai-credit.service'; import type { StartExecutionParams } from '../../agent-execution.service'; import type { AgentTurnExecutionService } from '../../agent-turn-execution.service'; +import { AgentTurnAlreadyRunningError } from '../../agent-turn-already-running.error'; import type { AgentsService } from '../../agents.service'; import { ExecutionRecorder } from '../../execution-recorder'; import type { AgentExecutionRepository } from '../../repositories/agent-execution.repository'; @@ -1134,6 +1135,160 @@ describe('AgentsBuilderService execution records', () => { expect.objectContaining({ agentId: 'agent-1' }), ); }); + + describe('best-effort recording', () => { + it('runs the turn without a record when the admission fails', async () => { + const { service, logger, user, credentialProvider, credentialService, turnExecutionService } = + setup(); + turnExecutionService.startExecution.mockRejectedValue(new AgentTurnAlreadyRunningError()); + agentsSdkMocks.nextChunks.push(finishChunk); + + const chunks = await drain( + service.buildAgent( + 'agent-1', + 'project-1', + 'hi', + credentialProvider, + credentialService, + user, + { ...baseSession, parentExecution }, + ), + ); + + expect(chunks).toEqual([finishChunk]); + expect(turnExecutionService.finalizeExecution).not.toHaveBeenCalled(); + expect(agentsSdkMocks.streamCalls[0]?.message).toBe('hi'); + expect(agentsSdkMocks.streamCalls[0]?.options).toEqual({ + persistence: { threadId: 'ia-builder:t:agent-1', resourceId: 'user-1' }, + abortSignal: baseSession.abortSignal, + recoverUsageOnAbort: true, + executionCounter, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to start the builder execution record, running without it', + expect.objectContaining({ agentId: 'agent-1', threadId: 'ia-builder:t:agent-1' }), + ); + }); + + it('runs the turn without a record when the memory thread cannot be saved', async () => { + const { + service, + user, + credentialProvider, + credentialService, + turnExecutionService, + memoryImplementation, + } = setup(); + memoryImplementation.saveThread.mockRejectedValue(new Error('db down')); + agentsSdkMocks.nextChunks.push(finishChunk); + + const chunks = await drain( + service.buildAgent( + 'agent-1', + 'project-1', + 'hi', + credentialProvider, + credentialService, + user, + { ...baseSession, parentExecution }, + ), + ); + + expect(chunks).toEqual([finishChunk]); + expect(turnExecutionService.startExecution).not.toHaveBeenCalled(); + expect(turnExecutionService.finalizeExecution).not.toHaveBeenCalled(); + }); + + it('resumes without a record when the admission fails', async () => { + const { + service, + user, + credentialProvider, + credentialService, + turnExecutionService, + n8nCheckpointStorage, + } = setup(); + n8nCheckpointStorage.getStatus.mockResolvedValue({ + status: 'active', + checkpoint: {} as never, + }); + turnExecutionService.startExecution.mockRejectedValue(new Error('db down')); + agentsSdkMocks.nextChunks.push(finishChunk); + + const chunks = await drain( + service.resumeBuild( + 'agent-1', + 'project-1', + 'builder-run-1', + 'tool-call-1', + { approved: true }, + credentialProvider, + credentialService, + user, + { ...baseSession, parentExecution }, + ), + ); + + expect(chunks).toEqual([finishChunk]); + expect(turnExecutionService.finalizeExecution).not.toHaveBeenCalled(); + expect(agentsSdkMocks.resumeCalls[0]?.options).toEqual({ + runId: 'builder-run-1', + toolCallId: 'tool-call-1', + abortSignal: baseSession.abortSignal, + recoverUsageOnAbort: true, + executionCounter, + }); + }); + + it('keeps the turn result when the record cannot be finalized', async () => { + const { service, logger, user, credentialProvider, credentialService, turnExecutionService } = + setup(); + turnExecutionService.finalizeExecution.mockRejectedValue(new Error('db down')); + agentsSdkMocks.nextChunks.push(finishChunk); + + const chunks = await drain( + service.buildAgent( + 'agent-1', + 'project-1', + 'hi', + credentialProvider, + credentialService, + user, + { ...baseSession, parentExecution }, + ), + ); + + expect(chunks).toEqual([finishChunk]); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to finalize the builder execution record', + expect.objectContaining({ executionId: 'builder-execution-1' }), + ); + }); + + it('rethrows the runtime error when the record cannot be finalized', async () => { + const { service, user, credentialProvider, credentialService, turnExecutionService } = + setup(); + turnExecutionService.finalizeExecution.mockRejectedValue(new Error('db down')); + const streamSpy = vi + .spyOn(agentsSdkMocks.MockAgent.prototype, 'stream') + .mockRejectedValueOnce(new Error('model unavailable')); + + await expect( + drain( + service.buildAgent( + 'agent-1', + 'project-1', + 'hi', + credentialProvider, + credentialService, + user, + { ...baseSession, parentExecution }, + ), + ), + ).rejects.toThrow('model unavailable'); + streamSpy.mockRestore(); + }); + }); }); describe('AgentsBuilderService state owner', () => { diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index 4e4250406b2..1f0f9d88ec2 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -40,7 +40,10 @@ import { AgentExecutionRepository, type AgentExecutionLinks, } from '../repositories/agent-execution.repository'; -import { EXECUTION_METADATA_KEY } from '../types/agent-queued-message'; +import { + EXECUTION_METADATA_KEY, + type AgentExecutionAdmission, +} from '../types/agent-queued-message'; import { bindExecutionInput } from '../utils/execution-input'; import { modelStreamStallOptions } from '../model-stream-stall-options'; import { buildAgentPreviewPath } from './agent-builder-preview-path'; @@ -225,7 +228,15 @@ export class AgentsBuilderService { yield* this.streamRecordedTurn( { agentId, user, session, parentExecution, userMessage: message }, - async ({ executionId, inputMessageIds, markStarted }) => { + async (turn) => { + if (!turn) { + return await builder.stream(message, { + persistence: { threadId: session.threadId, resourceId }, + ...options, + ...executionCounterOption(parentExecution), + }); + } + const { executionId, inputMessageIds, markStarted } = turn; markStarted(); return await builder.stream(bindExecutionInput(message, inputMessageIds), { persistence: { @@ -310,8 +321,15 @@ export class AgentsBuilderService { yield* this.streamRecordedTurn( { agentId, user, session, parentExecution, userMessage: null, resumeRunId: runId }, - async ({ executionId, recorder, markStarted }) => - await builder.resume('stream', resumeData, { + async (turn) => { + if (!turn) { + return await builder.resume('stream', resumeData, { + ...options, + ...executionCounterOption(parentExecution), + }); + } + const { executionId, recorder, markStarted } = turn; + return await builder.resume('stream', resumeData, { ...options, ...executionCounterOption(parentExecution), hostMetadata: { [EXECUTION_METADATA_KEY]: executionId }, @@ -319,7 +337,8 @@ export class AgentsBuilderService { markStarted(); recorder.recordHitlResponse(toolCallId, resumeData); }, - }), + }); + }, ); } @@ -436,10 +455,15 @@ export class AgentsBuilderService { * and memory belong to the same agent (see `getBuilderStateOwnerAgentId`), * so the admission finds them without an override. The memory thread keeps * the built agent id as information. + * + * Recording is best-effort. When the record cannot start, the turn runs + * without one (`openStream` gets `undefined`). When the record cannot + * finalize, the turn result does not change. Failures of the turn itself + * still go to the caller. */ private async *streamRecordedTurn( scope: BuilderTurnScope, - openStream: (turn: RecordedBuilderTurn) => Promise, + openStream: (turn: RecordedBuilderTurn | undefined) => Promise, ): AsyncGenerator { const { session, parentExecution } = scope; const executionLinks = await this.resolveExecutionLinks(scope); @@ -469,12 +493,12 @@ export class AgentsBuilderService { recording, ); session.abortSignal.throwIfAborted(); - await this.n8nMemory.getImplementation(parentExecution.agentId).saveThread({ - id: session.threadId, - resourceId: scope.user.id, - metadata: { [BUILT_AGENT_ID_METADATA_KEY]: scope.agentId }, - }); - const admission = await this.turnExecutionService.startExecution(recording, recorder.startedAt); + const admission = await this.startBuilderRecord(scope, recording, recorder.startedAt); + if (!admission) { + session.abortSignal.throwIfAborted(); + yield* this.streamFromAgent(await openStream(undefined)); + return; + } const { executionId } = admission; let executionStarted = false; let executionError: unknown; @@ -503,18 +527,56 @@ export class AgentsBuilderService { let hitlStatus: 'suspended' | 'resumed' | undefined; if (recorder.suspended) hitlStatus = 'suspended'; else if (scope.resumeRunId !== undefined && executionStarted) hitlStatus = 'resumed'; - await this.turnExecutionService.finalizeExecution({ - executionId, - executionStarted, - executionError, - params: { - ...recording, - record: session.abortSignal.aborted - ? { ...record, finishReason: 'cancelled', error: null } - : record, - hitlStatus, - }, + try { + await this.turnExecutionService.finalizeExecution({ + executionId, + executionStarted, + executionError, + params: { + ...recording, + record: session.abortSignal.aborted + ? { ...record, finishReason: 'cancelled', error: null } + : record, + hitlStatus, + }, + }); + } catch (error) { + // A throw here would replace the turn result or the turn error. + this.logger.warn('Failed to finalize the builder execution record', { + agentId: scope.agentId, + threadId: session.threadId, + executionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + /** + * Store the built agent id on the memory thread and admit the execution. + * Returns `undefined` and logs a warning when this fails, for example when + * another turn already runs on the thread. + */ + private async startBuilderRecord( + scope: BuilderTurnScope, + recording: StartExecutionParams, + startedAt: Date, + ): Promise { + const { threadId } = scope.session; + try { + await this.n8nMemory.getImplementation(scope.parentExecution.agentId).saveThread({ + id: threadId, + resourceId: scope.user.id, + metadata: { [BUILT_AGENT_ID_METADATA_KEY]: scope.agentId }, }); + return await this.turnExecutionService.startExecution(recording, startedAt); + } catch (error) { + this.logger.warn('Failed to start the builder execution record, running without it', { + agentId: scope.agentId, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + return undefined; } } diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts index dd81296e0ad..e95d6ed4f95 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -432,13 +432,23 @@ export class AgentExecutionThreadRepository extends BaseRepository { - return await this.findOneBy({ id: threadId, agentId, ownerId, accessScope: 'user' }); + return await this.findOneBy({ + id: threadId, + agentId, + ownerId, + accessScope: 'user', + parentThreadId: IsNull(), + }); } /** @@ -504,7 +514,13 @@ export class AgentExecutionThreadRepository extends BaseRepository { ).rejects.toThrow(NotFoundError); expect(txRunner.run).not.toHaveBeenCalled(); }); + + it('refuses to create a session with the id of a child session of the user', async () => { + const { service, threadRepository, txRunner } = setup(); + threadRepository.findOneBy.mockResolvedValue({ + ...thread, + id: 'ia-builder:thread-1:agent-a', + parentThreadId: 'thread-1', + } as never); + + await expect( + service.createThread({ + agentId: AGENT_ID, + user, + projectId: 'project-1', + threadId: 'ia-builder:thread-1:agent-a', + }), + ).rejects.toThrow(NotFoundError); + expect(txRunner.run).not.toHaveBeenCalled(); + }); + + it('returns an existing top-level session of the user', async () => { + const { service, threadRepository, txRunner } = setup(); + threadRepository.findOneBy.mockResolvedValue(thread); + + await expect( + service.createThread({ + agentId: AGENT_ID, + user, + projectId: 'project-1', + threadId: 'thread-1', + }), + ).resolves.toBe(thread); + expect(txRunner.run).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts b/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts index fe17269012e..5b5bf70a716 100644 --- a/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts +++ b/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts @@ -148,10 +148,12 @@ export class SystemAgentExecutionService { const threadId = params.threadId ?? randomUUID(); const existing = await this.threadRepository.findOneBy({ id: threadId }); if (existing) { + // A child session (for example an Agent builder session) is not a top-level session. if ( existing.agentId !== params.agentId || existing.accessScope !== 'user' || - existing.ownerId !== params.user.id + existing.ownerId !== params.user.id || + existing.parentThreadId !== null ) { throw new NotFoundError('Session not found'); } diff --git a/packages/cli/test/integration/agents/agent-builder-execution-records.test.ts b/packages/cli/test/integration/agents/agent-builder-execution-records.test.ts index 2f0b5e2c004..d7fec246b97 100644 --- a/packages/cli/test/integration/agents/agent-builder-execution-records.test.ts +++ b/packages/cli/test/integration/agents/agent-builder-execution-records.test.ts @@ -1,7 +1,8 @@ import type { SerializableAgentState } from '@n8n/agents'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { ProjectRepository, type User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { NotFoundError } from '@n8n/errors'; import { v4 as uuid } from 'uuid'; import { @@ -24,8 +25,9 @@ import { AgentObservationRepository } from '@/modules/agents/repositories/agent- import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { SystemAgentExecutionService } from '@/modules/agents/system-agents/system-agent-execution.service'; import { EXECUTION_METADATA_KEY } from '@/modules/agents/types/agent-queued-message'; +import { UserService } from '@/services/user.service'; -import { createMember } from '../shared/db/users'; +import { createMember, createOwner } from '../shared/db/users'; const INSTANCE_AGENT_ID = 'test-instance-assistant'; @@ -50,6 +52,7 @@ describe('Agent builder execution records', () => { let checkpointRepo: AgentCheckpointRepository; let observationRepo: AgentObservationRepository; let owner: User; + let admin: User; let workingProjectId: string; let targetProjectId: string; let targetAgentId: string; @@ -67,6 +70,7 @@ describe('Agent builder execution records', () => { checkpointRepo = Container.get(AgentCheckpointRepository); observationRepo = Container.get(AgentObservationRepository); owner = await createMember(); + admin = await createOwner(); await systemAgentService.register({ agentId: INSTANCE_AGENT_ID, name: 'Assistant', @@ -415,10 +419,135 @@ describe('Agent builder execution records', () => { const page = await threadRepo.findOwnedHistoryPage(INSTANCE_AGENT_ID, owner.id, 10); expect(page.threads.map(({ id }) => id)).toEqual([parent.threadId]); - // Known gap, closed in the next stacked PR: the single-session lookup - // has no parent filter, so it returns the builder session. expect( await threadRepo.findOwnedById(INSTANCE_AGENT_ID, owner.id, builder.threadId), - ).toMatchObject({ id: builder.threadId, parentThreadId: parent.threadId }); + ).toBeNull(); + expect((await threadRepo.findOwnedById(INSTANCE_AGENT_ID, owner.id, parent.threadId))?.id).toBe( + parent.threadId, + ); + }); + + it('does not open a builder session through the system agent session entry points', async () => { + const parent = await createParentTurn(); + const builder = await recordBuilderTurn(parent); + await executionRepo.update({ threadId: parent.threadId }, { status: 'success' }); + + await expect( + systemAgentService.getThread(INSTANCE_AGENT_ID, owner, builder.threadId), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.getUsableThread(INSTANCE_AGENT_ID, owner, builder.threadId), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.updateThread(INSTANCE_AGENT_ID, owner, builder.threadId, { + title: 'Renamed', + }), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.sendMessage({ + agentId: INSTANCE_AGENT_ID, + user: owner, + threadId: builder.threadId, + message: 'hi', + }), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.cancel(INSTANCE_AGENT_ID, owner, builder.threadId), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.deleteThread(INSTANCE_AGENT_ID, owner, builder.threadId), + ).rejects.toThrow(NotFoundError); + // A client cannot start a new session with the id of a builder session. + await expect( + systemAgentService.createThread({ + agentId: INSTANCE_AGENT_ID, + user: owner, + projectId: workingProjectId, + threadId: builder.threadId, + }), + ).rejects.toThrow(NotFoundError); + await expect( + systemAgentService.prepareChatMessage({ + agentId: INSTANCE_AGENT_ID, + user: owner, + sessionId: builder.threadId, + message: 'hi', + }), + ).rejects.toThrow(); + await expect( + systemAgentService.prepareChatMessage({ + agentId: INSTANCE_AGENT_ID, + user: owner, + projectId: workingProjectId, + sessionId: builder.threadId, + message: 'hi', + }), + ).rejects.toThrow(NotFoundError); + + expect(await threadRepo.findOneBy({ id: builder.threadId })).toMatchObject({ + title: null, + parentThreadId: parent.threadId, + }); + expect(await executionRepo.findByThreadIdOrdered(builder.threadId)).toHaveLength(1); + }); + + /** + * No service deletes system agent sessions when their owner is deleted. + * The database decides: the owner column is set to null, and deleting the + * personal project cascades to the sessions in it. A builder session has + * the working project of its parent, so it always shares the fate of its + * parent. + */ + describe('when the session owner is deleted', () => { + async function deleteUserWithSessions(projectOf: (user: User) => Promise) { + const user = await createMember(); + workingProjectId = await projectOf(user); + const parentThread = await threadRepo.save( + threadRepo.create({ + id: uuid(), + agentId: INSTANCE_AGENT_ID, + agentName: 'Assistant', + projectId: workingProjectId, + accessScope: 'user', + ownerId: user.id, + }), + ); + const parentExecution = await executionRepo.save( + executionRepo.create({ id: uuid(), threadId: parentThread.id, status: 'success' }), + ); + const previousOwner = owner; + owner = user; + try { + const builder = await recordBuilderTurn({ + threadId: parentThread.id, + executionId: parentExecution.id, + }); + await Container.get(UserService).deleteUser(admin, user.id); + return { parentThreadId: parentThread.id, builderThreadId: builder.threadId }; + } finally { + owner = previousOwner; + } + } + + it('keeps the parent and the builder session of a team project, without an owner', async () => { + const { parentThreadId, builderThreadId } = await deleteUserWithSessions( + async (user) => (await createTeamProject(undefined, user)).id, + ); + + for (const id of [parentThreadId, builderThreadId]) { + expect(await threadRepo.findOneBy({ id })).toMatchObject({ ownerId: null }); + } + expect(await executionRepo.findByThreadIdOrdered(builderThreadId)).toHaveLength(1); + }); + + it('deletes the parent and the builder session of the personal project together', async () => { + const { parentThreadId, builderThreadId } = await deleteUserWithSessions( + async (user) => + (await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id)).id, + ); + + expect(await threadRepo.findOneBy({ id: parentThreadId })).toBeNull(); + expect(await threadRepo.findOneBy({ id: builderThreadId })).toBeNull(); + }); }); }); diff --git a/packages/cli/test/integration/agents/agent-thread-repository-queries.test.ts b/packages/cli/test/integration/agents/agent-thread-repository-queries.test.ts index f358e062af0..efabbb9510e 100644 --- a/packages/cli/test/integration/agents/agent-thread-repository-queries.test.ts +++ b/packages/cli/test/integration/agents/agent-thread-repository-queries.test.ts @@ -125,6 +125,13 @@ describe('Agent thread repository queries for system agents', () => { expect(await threadRepo.findOwnedById(agentId, ownerId, shared.id)).toBeNull(); expect(await threadRepo.findOwnedById(agentId, ownerId, 'missing')).toBeNull(); }); + + it('does not return a child session with the same agent and owner', async () => { + const parent = await createThread(); + const child = await createThread({ parentThreadId: parent.id, parentAgentId: agentId }); + + expect(await threadRepo.findOwnedById(agentId, ownerId, child.id)).toBeNull(); + }); }); describe('updateOwned', () => { diff --git a/packages/cli/test/integration/agents/system-agent-builder-thread.api.test.ts b/packages/cli/test/integration/agents/system-agent-builder-thread.api.test.ts new file mode 100644 index 00000000000..15b3e5e0eae --- /dev/null +++ b/packages/cli/test/integration/agents/system-agent-builder-thread.api.test.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; + +import { createTeamProject, testModules } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { AgentExecutionThreadRepository } from '@/modules/agents/repositories/agent-execution-thread.repository'; +import { SystemAgentExecutionService } from '@/modules/agents/system-agents/system-agent-execution.service'; + +import { createMember } from '../shared/db/users'; +import { setupTestServer } from '../shared/utils'; + +const INSTANCE_AGENT_ID = 'test-instance-assistant'; + +beforeAll(async () => { + await testModules.loadModules(['agents']); +}); + +const server = setupTestServer({ endpointGroups: ['ai'] }); + +// An Agent builder session has the agent and owner of its parent Assistant +// session. The system agent routes must still treat it as unknown. +describe('System agent routes with an Agent builder session id', () => { + let user: User; + let parentThreadId: string; + let builderThreadId: string; + + beforeAll(async () => { + await Container.get(SystemAgentExecutionService).register({ + agentId: INSTANCE_AGENT_ID, + name: 'Assistant', + authorize: async () => true, + prepareTurn: vi.fn(), + }); + user = await createMember(); + const projectId = (await createTeamProject(undefined, user)).id; + const threadRepository = Container.get(AgentExecutionThreadRepository); + const session = { + agentId: INSTANCE_AGENT_ID, + projectId, + accessScope: 'user' as const, + ownerId: user.id, + }; + parentThreadId = randomUUID(); + builderThreadId = `ia-builder:${parentThreadId}:${randomUUID()}`; + await threadRepository.save( + threadRepository.create({ ...session, id: parentThreadId, agentName: 'Assistant' }), + ); + await threadRepository.save( + threadRepository.create({ + ...session, + id: builderThreadId, + agentName: 'agent-builder', + parentThreadId, + parentAgentId: INSTANCE_AGENT_ID, + }), + ); + }); + + it('returns the usage of the parent session', async () => { + await server + .authAgentFor(user) + .get(`/agents/system/${INSTANCE_AGENT_ID}/chat/${parentThreadId}/usage`) + .expect(200); + }); + + it.each(['usage', 'background-tasks'])( + 'returns 404 for the %s of the builder session', + async (route) => { + await server + .authAgentFor(user) + .get( + `/agents/system/${INSTANCE_AGENT_ID}/chat/${encodeURIComponent(builderThreadId)}/${route}`, + ) + .expect(404); + }, + ); +});