diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 84fc8d959..9eab85946 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -688,7 +688,17 @@ function resolveSpeechTransport(providerId: string | null | undefined): SpeechTr } function openTtsSession(): StageTtsSession { - return createStageTtsSession({ + // A session must only clear the module-level `currentSession` if it IS that session. The previous + // code cleared it whenever any `stream-` session completed, which is unsafe once sessions exist that + // are not assigned to `currentSession` (e.g. one-off read-aloud sessions): one of those finishing + // would null a still-active chat session and drop the rest of the reply. Capture the session and + // compare identity; the `stream-` guard is preserved so segmenter sessions still don't self-clear. + let session: StageTtsSession | null = null + const clearIfActive = () => { + if (session && currentSession === session && session.intentId.startsWith('stream-')) + currentSession = null + } + session = createStageTtsSession({ transport: resolveSpeechTransport(activeSpeechProvider.value), streaming: buildStreamingSnapshot, audioContext, @@ -706,15 +716,14 @@ function openTtsSession(): StageTtsSession { model: activeSpeechModel.value, error: err, }) - if (currentSession?.intentId.startsWith('stream-')) - currentSession = null + clearIfActive() }, onDone: () => { - if (currentSession?.intentId.startsWith('stream-')) - currentSession = null + clearIfActive() }, }, }) + return session } watch(latestStopRequest, (request) => { diff --git a/packages/stage-ui/src/libs/speech/streaming-pipeline.test.ts b/packages/stage-ui/src/libs/speech/streaming-pipeline.test.ts index cf9be0738..8dde9a754 100644 --- a/packages/stage-ui/src/libs/speech/streaming-pipeline.test.ts +++ b/packages/stage-ui/src/libs/speech/streaming-pipeline.test.ts @@ -287,11 +287,21 @@ describe('createStreamingTtsPipeline', () => { await server.startObserved handle.cancel() - await new Promise((resolve) => { - onDone.mockImplementation(() => resolve()) - setTimeout(resolve, 500) - }) - - expect(cancelObserved).toBe(true) + // ROOT CAUSE: + // + // This test was flaky on CI: asserting `cancelObserved` right after `onDone` + // resolved raced the mock server's `message` event. + // `cancel()` queues the cancel frame in the ws write buffer, then `terminate()` + // defers `ws.close()` + `onDone()` by one macrotask (streaming-pipeline.ts) — + // but the frame still has to cross a real loopback socket and be dispatched to + // the server's `message` listener, which on a loaded runner can happen AFTER + // the client-side `onDone` fired. + // + // We fixed this by polling for both observations instead of asserting + // immediately after `onDone`. + await vi.waitFor(() => { + expect(cancelObserved).toBe(true) + expect(onDone).toHaveBeenCalledTimes(1) + }, { interval: 10, timeout: 1500 }) }) }) diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts index 62d203bed..0e1d98508 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts @@ -300,4 +300,28 @@ describe('tools/character/orchestrator/spark-command', () => { expect(result).toContain('spark:command sent') expect(result).toContain(command.commandId) }) + + it('reports a broadcast without crashing when the channel sender clears destinations', async () => { + // The real sendSparkCommand (stores/llm.ts) deletes command.destinations to broadcast to every + // authenticated peer; the success message must not then call .join on undefined. + const sendSparkCommand = vi.fn((command: { destinations?: unknown }) => { + delete command.destinations + }) + const tools = await createSparkCommandTool({ sendSparkCommand }) + + const result = await tools[0].execute({ + destinations: [], + interrupt: 'soft', + priority: 'normal', + intent: 'action', + ack: null, + parentEventId: null, + guidance: null, + contexts: null, + }, { messages: [], toolCallId: 'tool-call-id' }) + + expect(sendSparkCommand).toHaveBeenCalledOnce() + expect(result).toContain('spark:command sent') + expect(result).toContain('broadcast') + }) }) diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts index 45e0fca3d..1f39d0ad3 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts @@ -65,7 +65,13 @@ export async function createSparkCommandTool(options: CreateSparkCommandToolOpti options.sendSparkCommand(command) - return `spark:command sent (${command.commandId}) to ${command.destinations.join(', ')}` + // `destinations` may be undefined: the channel sender (stores/llm.ts sendSparkCommand) deletes + // it to trigger broadcast-to-all-authenticated-peers. Guard the .join so we don't surface + // "Cannot read properties of undefined (reading 'join')" back to the LLM after a successful send. + const dests = Array.isArray(command.destinations) && command.destinations.length > 0 + ? command.destinations.join(', ') + : 'all authenticated peers (broadcast)' + return `spark:command sent (${command.commandId}) to ${dests}` }, }), ]