fix: rebind foreground run ID on post-tool-call assistant events (#545)

This commit is contained in:
Cameron
2026-03-09 23:12:53 -07:00
committed by GitHub
parent 1c9489683f
commit d3367e8c6a
2 changed files with 35 additions and 16 deletions

View File

@@ -1374,17 +1374,34 @@ 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.
// 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 {
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'})`);
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;
}
}
receivedAnyData = true;
msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1;

View File

@@ -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 () => {