From da19e5c28315fea8de4e28b9e16b6129d88b582b Mon Sep 17 00:00:00 2001 From: jay <1569941651@qq.com> Date: Tue, 13 Oct 2026 09:17:26 +0000 Subject: [PATCH] test(core): cover unfinished invocation guard before turn reclamation (#13257) * test(core): cover unfinished invocation guard before turn reclamation * test(core): also pin the prepared sub-case of the beginTurn unfinished guard * test(core): pin the cancel_requested sub-case and reflow for Prettier * test(core): harden the cancel_requested test per review round 3 - release the held onConfirm gate in a finally block so a failing assertion cannot stall afterEach dispose() behind the gate - assert the confirmation rejection reason instead of a bare await, matching the sibling cancellation tests * chore(ci): retrigger validation after review --------- Co-authored-by: Jay Feng --- .../src/tools/managed-tool-runtime.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/core/src/tools/managed-tool-runtime.test.ts b/packages/core/src/tools/managed-tool-runtime.test.ts index f71dd2229ab..b6f9814acb7 100644 --- a/packages/core/src/tools/managed-tool-runtime.test.ts +++ b/packages/core/src/tools/managed-tool-runtime.test.ts @@ -718,6 +718,68 @@ describe('ManagedToolRuntime', () => { expect(tool.invocations).toHaveLength(1025); }); + it('refuses a new turn while an invocation is prepared but not yet executed', async () => { + const ref = await prepare(); + await runtime.preflight(ref); + expect(() => + runtime.beginTurn({ ...identity, promptId: 'prompt-2' }), + ).toThrow('unfinished'); + expect(runtime.status(ref).state).toBe('prepared'); + expect(runtime.hasActiveWork()).toBe(true); + }); + + it('refuses a new turn while an invocation is still executing and preserves access to it', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const executing = runtime.execute(ref); + const next = { ...identity, promptId: 'prompt-2' }; + try { + expect(() => runtime.beginTurn(next)).toThrow('unfinished'); + expect(runtime.status(ref).state).toBe('executing'); + expect(runtime.hasActiveWork()).toBe(true); + } finally { + gate.resolve(rawResult); + await executing; + } + expect((await executing).executionStatus).toBe('success'); + expect(runtime.status(ref).state).toBe('settled'); + await runtime.beginTurn(next); + expect(() => runtime.status(ref)).toThrow('identity does not match'); + }); + + it('refuses a new turn while a cancellation is confirming', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.onConfirm.mockReturnValue(gate.promise); + const ref = await prepare(); + const confirming = runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + ); + const rejection = confirming.catch((error: unknown) => error); + try { + await vi.waitFor(() => + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1), + ); + expect(runtime.cancel(ref).state).toBe('cancel_requested'); + expect(() => + runtime.beginTurn({ ...identity, promptId: 'prompt-2' }), + ).toThrow('unfinished'); + expect(runtime.status(ref).state).toBe('cancel_requested'); + expect(runtime.hasActiveWork()).toBe(true); + } finally { + gate.resolve(); + } + expect(await rejection).toMatchObject({ + message: expect.stringContaining('cancelled'), + }); + await vi.waitFor(() => expect(runtime.status(ref).state).toBe('settled')); + expect(runtime.status(ref).result?.executionStatus).toBe('not_started'); + }); + it.each([ ['a replacement', { shouldProceed: true }, { value: 'rewritten' }], ['an empty replacement', { shouldProceed: true }, {}],