diff --git a/src/core/bot.ts b/src/core/bot.ts index ac1d80a..45f1d01 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -1374,16 +1374,33 @@ export class LettaBot implements AgentSession { continue; } } else if (expectedForegroundRunId && eventRunIds.length > 0 && !eventRunIds.includes(expectedForegroundRunId)) { - // Strict no-rebind policy: once foreground is selected, never switch. - sawCompetingRunEvent = true; - filteredRunEventCount++; - if (streamMsg.type === 'result') { - ignoredNonForegroundResultCount++; - log.warn(`Ignoring non-foreground result event (seq=${seq}, key=${convKey}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId}, source=${expectedForegroundRunSource || 'unknown'})`); + // After a tool call the Letta server may assign a new run ID for + // the continuation. Rebind on assistant events -- background Task + // agents run in separate sessions and don't produce assistant + // events in the foreground stream. Other event types (reasoning, + // tool_call, result) from different runs are still filtered to + // prevent background Task output leaking into user display (#482). + if (streamMsg.type === 'assistant') { + const newRunId = eventRunIds[0]; + log.info(`Rebinding foreground run: ${expectedForegroundRunId} -> ${newRunId} (seq=${seq}, key=${convKey}, type=${streamMsg.type})`); + expectedForegroundRunId = newRunId; + expectedForegroundRunSource = 'assistant'; + // Flush any buffered display events for the new run. + if (bufferedDisplayEvents.length > 0) { + await flushBufferedDisplayEventsForRun(newRunId); + } + // Fall through to normal processing } else { - log.trace(`Skipping non-foreground stream event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId})`); + sawCompetingRunEvent = true; + filteredRunEventCount++; + if (streamMsg.type === 'result') { + ignoredNonForegroundResultCount++; + log.warn(`Ignoring non-foreground result event (seq=${seq}, key=${convKey}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId})`); + } else { + log.trace(`Skipping non-foreground stream event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId})`); + } + continue; } - continue; } receivedAnyData = true; diff --git a/src/core/result-guard.test.ts b/src/core/result-guard.test.ts index 55dc3e3..0e76833 100644 --- a/src/core/result-guard.test.ts +++ b/src/core/result-guard.test.ts @@ -159,7 +159,7 @@ describe('result divergence guard', () => { expect(lastSent).toMatch(/\(.*\)/); // Parenthesized system message }); - it('ignores non-foreground result events and waits for the foreground result', async () => { + it('rebinds foreground run on post-tool-call assistant events with new run ID (#527)', async () => { const bot = new LettaBot({ workingDir: workDir, allowedTools: [], @@ -179,14 +179,15 @@ describe('result divergence guard', () => { sendFile: vi.fn(async () => ({ messageId: 'file-1' })), }; + // Server assigns run-2 after tool call -- both runs are part of the same turn (bot as any).sessionManager.runSession = vi.fn(async () => ({ session: { abort: vi.fn(async () => {}) }, stream: async function* () { - yield { type: 'assistant', content: 'main ', runId: 'run-main' }; - yield { type: 'assistant', content: 'background', runId: 'run-bg' }; - yield { type: 'result', success: true, result: 'background final', runIds: ['run-bg'] }; - yield { type: 'assistant', content: 'reply', runId: 'run-main' }; - yield { type: 'result', success: true, result: 'main reply', runIds: ['run-main'] }; + yield { type: 'assistant', content: 'Before tool. ', runId: 'run-1' }; + yield { type: 'tool_call', toolCallId: 'tc-1', toolName: 'Bash', toolInput: { command: 'echo ok' }, runId: 'run-1' }; + yield { type: 'tool_result', content: 'ok', isError: false, runId: 'run-1' }; + yield { type: 'assistant', content: 'After tool.', runId: 'run-2' }; + yield { type: 'result', success: true, result: 'Before tool. After tool.', runIds: ['run-2'] }; }, })); @@ -194,14 +195,15 @@ describe('result divergence guard', () => { channel: 'discord', chatId: 'chat-1', userId: 'user-1', - text: 'hello', + text: 'run a command', timestamp: new Date(), }; await (bot as any).processMessage(msg, adapter); const sentTexts = adapter.sendMessage.mock.calls.map(([payload]) => payload.text); - expect(sentTexts).toEqual(['main reply']); + // Pre-tool and post-tool text are separate messages (finalized on type change) + expect(sentTexts).toEqual(['Before tool. ', 'After tool.']); }); it('buffers pre-foreground run-scoped display events and drops non-foreground buffers', async () => {