diff --git a/src/gateway/talk-realtime-relay-tool-call-ledger.test.ts b/src/gateway/talk-realtime-relay-tool-call-ledger.test.ts index 0415329ad542..be8c4a725bd5 100644 --- a/src/gateway/talk-realtime-relay-tool-call-ledger.test.ts +++ b/src/gateway/talk-realtime-relay-tool-call-ledger.test.ts @@ -2,16 +2,21 @@ import { describe, expect, it, vi } from "vitest"; import { RelayToolCallLedger } from "./talk-realtime-relay-tool-call-ledger.js"; describe("RelayToolCallLedger", () => { - it("shares one retained identity across cancellation and terminal outcomes", () => { + it("preserves the first cancellation turn until agent completion wins", () => { const ledger = new RelayToolCallLedger({ onOverflow: vi.fn(), maxEntries: 2 }); expect(ledger.tryAdmit(["call-1"])).toBe(true); expect(ledger.markCancelled(["call-1"], "turn-1")).toBe(true); + expect(ledger.markCancelled(["call-1"], "turn-2")).toBe(true); + expect(ledger.cancelledTurnId("call-1")).toBe("turn-1"); + expect(ledger.markAgentCompleted(["call-1"])).toBe(true); + expect(ledger.hasCancelled("call-1")).toBe(false); + expect(ledger.markCancelled(["call-1"], "turn-3")).toBe(true); expect(ledger.markProviderCompleted(["call-1"])).toBe(true); expect(ledger.size).toBe(1); - expect(ledger.cancelledTurnId("call-1")).toBe("turn-1"); + expect(ledger.cancelledTurnId("call-1")).toBeUndefined(); expect(ledger.isAgentCompleted("call-1")).toBe(true); expect(ledger.isProviderCompleted("call-1")).toBe(true); }); @@ -35,9 +40,9 @@ describe("RelayToolCallLedger", () => { const ledger = new RelayToolCallLedger({ onOverflow: vi.fn(), maxEntries: 1 }); ledger.markCancelled(["call-1"], "turn-1"); + ledger.deleteCancelled("call-1"); ledger.markAgentCompleted(["call-1"]); ledger.markProviderCompleted(["call-1"]); - ledger.deleteCancelled("call-1"); ledger.deleteAgentCompleted("call-1"); ledger.clearProviderCompleted(); diff --git a/src/gateway/talk-realtime-relay-tool-call-ledger.ts b/src/gateway/talk-realtime-relay-tool-call-ledger.ts index 72a2f30a133d..0830e75e9df3 100644 --- a/src/gateway/talk-realtime-relay-tool-call-ledger.ts +++ b/src/gateway/talk-realtime-relay-tool-call-ledger.ts @@ -73,7 +73,10 @@ export class RelayToolCallLedger { markAgentCompleted(callIds: Iterable): boolean { return this.mark(callIds, (entry) => { + // Completion is terminal for the owner-facing call. Do not let stale + // cancellation metadata route a later result through cancellation again. entry.agentCompleted = true; + delete entry.cancelledTurnId; }); } @@ -111,7 +114,11 @@ export class RelayToolCallLedger { markCancelled(callIds: Iterable, turnId: string): boolean { return this.mark(callIds, (entry) => { - entry.cancelledTurnId = turnId; + // The first cancel owns the original turn until completion. Retries and + // late cancellation events must not replace or revive that lifecycle fact. + if (!entry.agentCompleted && entry.cancelledTurnId === undefined) { + entry.cancelledTurnId = turnId; + } }); } diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index d0182f231da0..c97e0a24719f 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -3988,6 +3988,7 @@ describe("talk realtime gateway relay", () => { it("fails closed when retained relay tool-call identities reach their hard cap", () => { let bridgeRequest: RealtimeVoiceBridgeCreateRequest | undefined; const close = vi.fn(); + const submitToolResult = vi.fn(); const provider: RealtimeVoiceProviderPlugin = { id: "relay-test", label: "Relay Test", @@ -3999,7 +4000,7 @@ describe("talk realtime gateway relay", () => { sendAudio: vi.fn(), setMediaTimestamp: vi.fn(), handleBargeIn: vi.fn(), - submitToolResult: vi.fn(), + submitToolResult, acknowledgeMark: vi.fn(), close, isConnected: vi.fn(() => true), @@ -4017,6 +4018,13 @@ describe("talk realtime gateway relay", () => { }); const retainedSession = relaySessions.get(session.relaySessionId); expect(retainedSession).toBeDefined(); + const toolCallEventCount = () => + broadcastToConnIds.mock.calls.filter( + ([, payload]) => + typeof payload === "object" && + payload !== null && + (payload as { type?: string }).type === "toolCall", + ).length; for (let index = 0; index < MAX_RELAY_TOOL_CALL_IDENTITIES; index += 1) { bridgeRequest?.onToolCall?.({ @@ -4029,6 +4037,7 @@ describe("talk realtime gateway relay", () => { expect(retainedSession?.toolCalls.size).toBe(MAX_RELAY_TOOL_CALL_IDENTITIES); expect(relaySessions.has(session.relaySessionId)).toBe(true); + expect(toolCallEventCount()).toBe(MAX_RELAY_TOOL_CALL_IDENTITIES); bridgeRequest?.onToolCall?.({ itemId: "item-overflow", @@ -4039,6 +4048,9 @@ describe("talk realtime gateway relay", () => { expect(close).toHaveBeenCalledOnce(); expect(relaySessions.has(session.relaySessionId)).toBe(false); + expect(retainedSession?.toolCalls.has("call-overflow")).toBe(false); + expect(toolCallEventCount()).toBe(MAX_RELAY_TOOL_CALL_IDENTITIES); + expect(submitToolResult).not.toHaveBeenCalled(); expect( broadcastToConnIds.mock.calls.filter( ([, payload]) => @@ -4064,6 +4076,9 @@ describe("talk realtime gateway relay", () => { }); expect(close).toHaveBeenCalledOnce(); expect(retainedSession?.toolCalls.size).toBe(MAX_RELAY_TOOL_CALL_IDENTITIES); + expect(retainedSession?.toolCalls.has("call-late")).toBe(false); + expect(toolCallEventCount()).toBe(MAX_RELAY_TOOL_CALL_IDENTITIES); + expect(submitToolResult).not.toHaveBeenCalled(); }); it("caps active relay sessions per browser connection", () => {