diff --git a/src/channels/factory.ts b/src/channels/factory.ts index 6a10abc..2f05b3c 100644 --- a/src/channels/factory.ts +++ b/src/channels/factory.ts @@ -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; + const whatsappRaw = agentConfig.channels.whatsapp! as unknown as Record; if (whatsappRaw.streaming) { log.warn('WhatsApp does not support streaming (message edits not available). Streaming setting will be ignored for WhatsApp.'); } diff --git a/src/core/errors.test.ts b/src/core/errors.test.ts index dd188c1..5108ff1 100644 --- a/src/core/errors.test.ts +++ b/src/core/errors.test.ts @@ -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({ diff --git a/src/core/errors.ts b/src/core/errors.ts index 477a90a..5776f41 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -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'); diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index 557cd02..9d1e8e9 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -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; diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index cd038a6..279a1d8 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -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') {