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,
|
isEnabled: (agentConfig) => !!agentConfig.channels.whatsapp?.enabled,
|
||||||
build: (agentConfig, options) => {
|
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) {
|
if (whatsappRaw.streaming) {
|
||||||
log.warn('WhatsApp does not support streaming (message edits not available). Streaming setting will be ignored for WhatsApp.');
|
log.warn('WhatsApp does not support streaming (message edits not available). Streaming setting will be ignored for WhatsApp.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isAgentMissingFromInitError,
|
isAgentMissingFromInitError,
|
||||||
isApprovalConflictError,
|
isApprovalConflictError,
|
||||||
isConversationMissingError,
|
isConversationMissingError,
|
||||||
|
isInvalidToolCallIdsError,
|
||||||
} from './errors.js';
|
} from './errors.js';
|
||||||
|
|
||||||
describe('isApprovalConflictError', () => {
|
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', () => {
|
describe('formatApiErrorForUser', () => {
|
||||||
it('maps out-of-credits messages', () => {
|
it('maps out-of-credits messages', () => {
|
||||||
const msg = formatApiErrorForUser({
|
const msg = formatApiErrorForUser({
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export function isAgentMissingFromInitError(error: unknown): boolean {
|
|||||||
* When this happens, the conversation is permanently stuck -- the pending
|
* When this happens, the conversation is permanently stuck -- the pending
|
||||||
* approval can never be resolved because the server expects different IDs.
|
* approval can never be resolved because the server expects different IDs.
|
||||||
* The conversation must be cleared and recreated.
|
* 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 {
|
export function isInvalidToolCallIdsError(details: string): boolean {
|
||||||
return details.toLowerCase().includes('invalid tool call id');
|
return details.toLowerCase().includes('invalid tool call id');
|
||||||
|
|||||||
@@ -495,6 +495,67 @@ describe('SDK session contract', () => {
|
|||||||
expect(initialSession.close).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('passes memfs: true to resumeSession when config sets memfs true', async () => {
|
||||||
const mockSession = {
|
const mockSession = {
|
||||||
initialize: vi.fn(async () => undefined),
|
initialize: vi.fn(async () => undefined),
|
||||||
@@ -1102,6 +1163,69 @@ describe('SDK session contract', () => {
|
|||||||
expect(sentTexts).toContain('after default recovery');
|
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 () => {
|
it('passes tags: [origin:lettabot] to createAgent when creating a new agent', async () => {
|
||||||
delete process.env.LETTA_AGENT_ID;
|
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
|
// 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.
|
// 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)) {
|
if (isInvalidToolCallIdsError(result.details)) {
|
||||||
log.warn(`Clearing stuck conversation (key=${key}) due to invalid tool call IDs mismatch`);
|
log.warn(`Clearing stuck conversation (key=${key}) due to invalid tool call IDs mismatch`);
|
||||||
if (key !== 'shared') {
|
if (key !== 'shared') {
|
||||||
@@ -597,6 +599,8 @@ export class SessionManager {
|
|||||||
: await recoverPendingApprovalsForAgent(this.store.agentId);
|
: await recoverPendingApprovalsForAgent(this.store.agentId);
|
||||||
// Even on partial recovery, if any denial failed with mismatched IDs the
|
// 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.
|
// 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)) {
|
if (isInvalidToolCallIdsError(result.details)) {
|
||||||
log.warn(`Clearing stuck conversation (key=${convKey}) due to invalid tool call IDs mismatch, retrying with fresh conversation`);
|
log.warn(`Clearing stuck conversation (key=${convKey}) due to invalid tool call IDs mismatch, retrying with fresh conversation`);
|
||||||
if (convKey !== 'shared') {
|
if (convKey !== 'shared') {
|
||||||
|
|||||||
Reference in New Issue
Block a user