test(session): cover invalid tool-call mismatch recovery paths (#562)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user