test(session): cover invalid tool-call mismatch recovery paths (#562)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-11 15:29:24 -07:00
committed by GitHub
parent 8973e97464
commit 6231af560d
5 changed files with 148 additions and 1 deletions

View File

@@ -55,7 +55,7 @@ const SHARED_CHANNEL_BUILDERS: SharedChannelBuilder[] = [
{
isEnabled: (agentConfig) => !!agentConfig.channels.whatsapp?.enabled,
build: (agentConfig, options) => {
const whatsappRaw = agentConfig.channels.whatsapp! as Record<string, unknown>;
const whatsappRaw = agentConfig.channels.whatsapp! as unknown as Record<string, unknown>;
if (whatsappRaw.streaming) {
log.warn('WhatsApp does not support streaming (message edits not available). Streaming setting will be ignored for WhatsApp.');
}

View File

@@ -4,6 +4,7 @@ import {
isAgentMissingFromInitError,
isApprovalConflictError,
isConversationMissingError,
isInvalidToolCallIdsError,
} from './errors.js';
describe('isApprovalConflictError', () => {
@@ -42,6 +43,20 @@ describe('isAgentMissingFromInitError', () => {
});
});
describe('isInvalidToolCallIdsError', () => {
it('matches invalid tool call IDs details case-insensitively', () => {
expect(isInvalidToolCallIdsError(
"Failed to deny 1 approval(s) from run run-1: Invalid tool call IDs. Expected '['call_a']', but received '['call_b']'"
)).toBe(true);
expect(isInvalidToolCallIdsError('invalid tool call id mismatch')).toBe(true);
});
it('returns false for unrelated details', () => {
expect(isInvalidToolCallIdsError('No unresolved approval requests found')).toBe(false);
expect(isInvalidToolCallIdsError('Failed to check run run-1')).toBe(false);
});
});
describe('formatApiErrorForUser', () => {
it('maps out-of-credits messages', () => {
const msg = formatApiErrorForUser({

View File

@@ -64,6 +64,10 @@ export function isAgentMissingFromInitError(error: unknown): boolean {
* When this happens, the conversation is permanently stuck -- the pending
* approval can never be resolved because the server expects different IDs.
* The conversation must be cleared and recreated.
*
* TEMP(letta-code-sdk): remove once the SDK emits stable typed approval
* terminalization (for example, approval_conflict_terminal) so callers do not
* need to parse detail strings.
*/
export function isInvalidToolCallIdsError(details: string): boolean {
return details.toLowerCase().includes('invalid tool call id');

View File

@@ -495,6 +495,67 @@ describe('SDK session contract', () => {
expect(initialSession.close).toHaveBeenCalledTimes(1);
});
it('clears stuck shared conversation during proactive recovery when details include invalid tool call IDs', async () => {
const initialSession = {
initialize: vi.fn(async () => undefined),
bootstrapState: vi.fn(async () => ({ hasPendingApproval: true, conversationId: 'conv-stuck' })),
send: vi.fn(async (_message: unknown) => undefined),
stream: vi.fn(() =>
(async function* () {
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conv-stuck',
};
const recoveredSession = {
initialize: vi.fn(async () => undefined),
bootstrapState: vi.fn(async () => ({ hasPendingApproval: false, conversationId: 'conv-fresh' })),
send: vi.fn(async (_message: unknown) => undefined),
stream: vi.fn(() =>
(async function* () {
yield { type: 'assistant', content: 'proactive recovered' };
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conv-fresh',
};
vi.mocked(recoverOrphanedConversationApproval).mockResolvedValueOnce({
recovered: true,
details: "Denied 1 approval(s) from failed run run-ok; Failed to deny 1 approval(s) from run run-stuck: Invalid tool call IDs. Expected '['call_a']', but received '['call_b']'",
});
vi.mocked(resumeSession)
.mockReturnValueOnce(initialSession as never)
.mockReturnValueOnce(recoveredSession as never);
const bot = new LettaBot({
workingDir: join(dataDir, 'working'),
allowedTools: [],
});
bot.setAgentId('agent-contract-test');
const botInternal = bot as unknown as { store: { conversationId: string | null } };
botInternal.store.conversationId = 'conv-stuck';
const response = await bot.sendToAgent('hello');
expect(response).toBe('proactive recovered');
expect(recoverOrphanedConversationApproval).toHaveBeenCalledWith(
'agent-contract-test',
'conv-stuck',
true
);
expect(vi.mocked(resumeSession)).toHaveBeenCalledTimes(2);
expect(vi.mocked(resumeSession).mock.calls[0][0]).toBe('conv-stuck');
expect(vi.mocked(resumeSession).mock.calls[1][0]).toBe('agent-contract-test');
expect(initialSession.close).toHaveBeenCalledTimes(1);
});
it('passes memfs: true to resumeSession when config sets memfs true', async () => {
const mockSession = {
initialize: vi.fn(async () => undefined),
@@ -1102,6 +1163,69 @@ describe('SDK session contract', () => {
expect(sentTexts).toContain('after default recovery');
});
it('clears stuck shared conversation during reactive conflict recovery when details include invalid tool call IDs', async () => {
const conflictError = new Error(
'CONFLICT: Cannot send a new message: The agent is waiting for approval on a tool call.'
);
const stuckSession = {
initialize: vi.fn(async () => undefined),
bootstrapState: vi.fn(async () => ({ hasPendingApproval: false, conversationId: 'conv-stuck' })),
send: vi.fn(async () => {
throw conflictError;
}),
stream: vi.fn(() =>
(async function* () {
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conv-stuck',
};
const recoveredSession = {
initialize: vi.fn(async () => undefined),
bootstrapState: vi.fn(async () => ({ hasPendingApproval: false, conversationId: 'conv-fresh' })),
send: vi.fn(async (_message: unknown) => undefined),
stream: vi.fn(() =>
(async function* () {
yield { type: 'assistant', content: 'reactive recovered' };
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conv-fresh',
};
vi.mocked(recoverOrphanedConversationApproval).mockResolvedValueOnce({
recovered: true,
details: "Denied 1 approval(s) from failed run run-ok; Failed to deny 1 approval(s) from run run-stuck: Invalid tool call IDs. Expected '['call_a']', but received '['call_b']'",
});
vi.mocked(resumeSession)
.mockReturnValueOnce(stuckSession as never)
.mockReturnValueOnce(recoveredSession as never);
const bot = new LettaBot({
workingDir: join(dataDir, 'working'),
allowedTools: [],
});
bot.setAgentId('agent-contract-test');
const botInternal = bot as unknown as { store: { conversationId: string | null } };
botInternal.store.conversationId = 'conv-stuck';
const response = await bot.sendToAgent('hello');
expect(response).toBe('reactive recovered');
expect(recoverOrphanedConversationApproval).toHaveBeenCalledWith('agent-contract-test', 'conv-stuck');
expect(vi.mocked(resumeSession)).toHaveBeenCalledTimes(2);
expect(vi.mocked(resumeSession).mock.calls[0][0]).toBe('conv-stuck');
expect(vi.mocked(resumeSession).mock.calls[1][0]).toBe('agent-contract-test');
expect(stuckSession.close).toHaveBeenCalledTimes(1);
});
it('passes tags: [origin:lettabot] to createAgent when creating a new agent', async () => {
delete process.env.LETTA_AGENT_ID;

View File

@@ -401,6 +401,8 @@ export class SessionManager {
}
// Even on partial recovery, if any denial failed with mismatched IDs the
// conversation may still be stuck. Clear it so the retry creates a fresh one.
// TEMP(letta-code-sdk): remove this detail-string fallback once the SDK
// exposes typed terminal approval conflicts with built-in recovery policy.
if (isInvalidToolCallIdsError(result.details)) {
log.warn(`Clearing stuck conversation (key=${key}) due to invalid tool call IDs mismatch`);
if (key !== 'shared') {
@@ -597,6 +599,8 @@ export class SessionManager {
: await recoverPendingApprovalsForAgent(this.store.agentId);
// Even on partial recovery, if any denial failed with mismatched IDs the
// conversation may still be stuck. Clear it so the retry creates a fresh one.
// TEMP(letta-code-sdk): remove this detail-string fallback once the SDK
// exposes typed terminal approval conflicts with built-in recovery policy.
if (isInvalidToolCallIdsError(result.details)) {
log.warn(`Clearing stuck conversation (key=${convKey}) due to invalid tool call IDs mismatch, retrying with fresh conversation`);
if (convKey !== 'shared') {