diff --git a/.changeset/reject-approval-resume-of-running-turn.md b/.changeset/reject-approval-resume-of-running-turn.md new file mode 100644 index 000000000..098edea75 --- /dev/null +++ b/.changeset/reject-approval-resume-of-running-turn.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge": patch +"@truefoundry/trueforge-core": patch +--- + +Reject approval and tool-response resumes that target a still-running turn before the cancelled-for-next-turn freeze, so an invalid resume such as a duplicate approval no longer cancels the turn it races. diff --git a/packages/trueforge-core/src/agent-session/SessionHandle.ts b/packages/trueforge-core/src/agent-session/SessionHandle.ts index 23acbc0e7..5c0ea56a3 100644 --- a/packages/trueforge-core/src/agent-session/SessionHandle.ts +++ b/packages/trueforge-core/src/agent-session/SessionHandle.ts @@ -1,6 +1,7 @@ /** * Bound session handle: starts turns via {@link SessionHandle.createTurn}. */ +import { InvalidAgentSendInputError } from '../core/errors'; import { newEventId } from '../core/events/schema'; import type { AgentDefinition } from '../core/runtime/AgentDefinition'; import { AgentThread } from '../core/runtime/AgentThread'; @@ -185,6 +186,29 @@ export class SessionHandle< update_session_title_if_not_exist?: string | undefined; }): Promise> { const previousTurnId = resolvePreviousTurnId(input.previous_turn_id, this.session.last_turn_id); + + // Approval and tool-response items answer the required actions of a + // completed turn; a running previous turn cannot have any. Check that + // read-only BEFORE the freeze below, so an invalid resume (e.g. a + // duplicate approval racing the turn it already resumed) is rejected + // without cancelling a turn that is still executing (#508). + const items = input.input ?? []; + if ( + previousTurnId !== null && + items.length > 0 && + items.every(msg => isApprovalDecisionMessage(msg) || isClientSideToolResponseMessage(msg)) + ) { + const prior = await this.store.getTurn({ + session_id: this.session.session_id, + turn_id: previousTurnId, + }); + if (prior?.state.status === 'running') { + throw new InvalidAgentSendInputError( + `cannot resume previous turn '${previousTurnId}' while it is still running`, + ); + } + } + const previous = previousTurnId ? await this.freezeTurn({ turn_id: previousTurnId, diff --git a/packages/trueforge-core/tests/agent-session/sessions.test.ts b/packages/trueforge-core/tests/agent-session/sessions.test.ts index 3c37ec1fc..857169939 100644 --- a/packages/trueforge-core/tests/agent-session/sessions.test.ts +++ b/packages/trueforge-core/tests/agent-session/sessions.test.ts @@ -1,9 +1,11 @@ +import { MAIN_THREAD_ID } from '../../src/agent-session/models/TurnRecord'; import { EventType } from '../../src/agent-session/schemas/events'; import { CancellationReason } from '../../src/agent-session/schemas/turn'; import { Sessions } from '../../src/agent-session/Sessions'; import { InMemorySessionStore } from '../../src/agent-session/store/InMemorySessionStore'; import { TurnNotFoundError } from '../../src/agent-session/store/SessionStoreErrors'; import { TurnHandle } from '../../src/agent-session/TurnHandle'; +import { InvalidAgentSendInputError } from '../../src/core/errors'; import { makeAgentSpec, makeTestResolver, mintTestTurnId } from './testHelpers'; describe('Sessions / SessionHandle / TurnHandle (storage + createTurn)', () => { @@ -128,6 +130,59 @@ describe('Sessions / SessionHandle / TurnHandle (storage + createTurn)', () => { ).rejects.toBeInstanceOf(TurnNotFoundError); }); + it('rejects an approval-only resume of a running turn without cancelling it', async () => { + const store = new InMemorySessionStore(); + const sessions = new Sessions({ sessionStore: store }); + const session = await sessions.create({ + tenant_id: tenant, + session_id: 's1', + created_by: 'user-1', + agent: { type: 'inline', spec: makeAgentSpec() }, + }); + const running = await session.createTurn({ + turn_id: mintTestTurnId(), + input: [{ type: EventType.USER_MESSAGE, content: 'hello' }], + previous_turn_id: 'none', + signal: new AbortController().signal, + resolver: makeTestResolver(), + }); + + // A duplicate or stray approval must not kill the turn it races (#508). + await expect( + session.createTurn({ + turn_id: mintTestTurnId(), + input: [ + { + type: EventType.USER_TOOL_APPROVAL, + thread_id: MAIN_THREAD_ID, + tool_call_id: 'call-1', + approval: { status: 'allow' }, + }, + ], + previous_turn_id: 'auto', + signal: new AbortController().signal, + resolver: makeTestResolver(), + }), + ).rejects.toBeInstanceOf(InvalidAgentSendInputError); + const untouched = await store.getTurn({ session_id: 's1', turn_id: running.id }); + expect(untouched?.state.status).toBe('running'); + + // A user message still barges in and cancels the running turn. + const barged = await session.createTurn({ + turn_id: mintTestTurnId(), + input: [{ type: EventType.USER_MESSAGE, content: 'interrupt' }], + previous_turn_id: 'auto', + signal: new AbortController().signal, + resolver: makeTestResolver(), + }); + expect(barged.state.status).toBe('running'); + const cancelled = await store.getTurn({ session_id: 's1', turn_id: running.id }); + expect(cancelled?.state).toMatchObject({ + status: 'cancelled', + reason: CancellationReason.CancelledForNextTurn, + }); + }); + it('custom value vs merge-fn', async () => { const store = new InMemorySessionStore<{ tag: string }, { n: number }>(); const sessions = new Sessions({ sessionStore: store });