feat: use SDK recoverPendingApprovals for approval conflict recovery (#585)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-12 22:28:14 -07:00
committed by GitHub
parent 227b986396
commit acfb90e2e5
5 changed files with 155 additions and 82 deletions

64
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1", "@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12", "@letta-ai/letta-client": "^1.7.12",
"@letta-ai/letta-code-sdk": "^0.1.9", "@letta-ai/letta-code-sdk": "^0.1.11",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
@@ -1350,15 +1350,17 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@letta-ai/letta-code": { "node_modules/@letta-ai/letta-code": {
"version": "0.17.1", "version": "0.18.2",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.17.1.tgz", "resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.18.2.tgz",
"integrity": "sha512-iLOWfh1ccmkdrx8j4y/Aop4H5D5PAfjxNVGM28TukcS0FZNPbnmDFGA0tcNudi6wslH6BT5X53/gkAIabuIujg==", "integrity": "sha512-HzNqMjBUiAq5IyZ8DSSWBHq/ahkd4RRYfO/V9eXMBZRTRpLb7Dae2hwvicE+aRSLmJqMdxpH6WI7+ZHKlFsILQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@letta-ai/letta-client": "^1.7.11", "@letta-ai/letta-client": "^1.7.12",
"glob": "^13.0.0", "glob": "^13.0.0",
"highlight.js": "^11.11.1",
"ink-link": "^5.0.0", "ink-link": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.2.0", "open": "^10.2.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ws": "^8.19.0" "ws": "^8.19.0"
@@ -1374,12 +1376,12 @@
} }
}, },
"node_modules/@letta-ai/letta-code-sdk": { "node_modules/@letta-ai/letta-code-sdk": {
"version": "0.1.10", "version": "0.1.11",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.10.tgz", "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.11.tgz",
"integrity": "sha512-idNRvPI6RbBho0jzm46NbMM4xjRPXLTvOniKbvimnlHDRkx6acsZy1exeu56Xmkpx83orvdcjqsuccBqnZFxNA==", "integrity": "sha512-P1ueLWQuCnERizrvU3fZ9/rrMAJSIT+2j2/xxptqxMOKUuUrDmvAix1/eyDXqAwZkBVGImyqLGm4zqwNVNA7Dg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@letta-ai/letta-code": "0.17.1" "@letta-ai/letta-code": "0.18.2"
} }
}, },
"node_modules/@letta-ai/letta-code/node_modules/balanced-match": { "node_modules/@letta-ai/letta-code/node_modules/balanced-match": {
@@ -2311,6 +2313,15 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -2412,8 +2423,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/@types/update-notifier": { "node_modules/@types/update-notifier": {
"version": "6.0.8", "version": "6.0.8",
@@ -2624,9 +2634,9 @@
} }
}, },
"node_modules/@vscode/ripgrep": { "node_modules/@vscode/ripgrep": {
"version": "1.17.0", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.1.tgz",
"integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==", "integrity": "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3597,7 +3607,6 @@
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -3616,7 +3625,6 @@
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.0" "dequal": "^2.0.0"
}, },
@@ -4588,6 +4596,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hookified": { "node_modules/hookified": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
@@ -5402,6 +5419,21 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",

View File

@@ -70,7 +70,7 @@
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1", "@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12", "@letta-ai/letta-client": "^1.7.12",
"@letta-ai/letta-code-sdk": "^0.1.9", "@letta-ai/letta-code-sdk": "^0.1.11",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",

View File

@@ -1557,34 +1557,34 @@ export class LettaBot implements AgentSession {
// Approval conflict recovery // Approval conflict recovery
if (retryDecision.isApprovalConflict && !retried && this.store.agentId) { if (retryDecision.isApprovalConflict && !retried && this.store.agentId) {
if (retryConvId) { log.info('Approval conflict detected -- attempting SDK recovery...');
log.info('Approval conflict detected -- attempting targeted recovery...'); clearInterval(typingInterval);
// Try SDK-level recovery first (through CLI control protocol)
if (session) {
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 });
if (sdkResult.recovered) {
log.info('SDK approval recovery succeeded, retrying message...');
this.sessionManager.invalidateSession(retryConvKey); this.sessionManager.invalidateSession(retryConvKey);
session = null; session = null;
clearInterval(typingInterval);
const convResult = await recoverOrphanedConversationApproval(
this.store.agentId, retryConvId, true,
);
if (convResult.recovered) {
log.info(`Approval recovery succeeded (${convResult.details}), retrying message...`);
return this.processMessage(msg, adapter, true); return this.processMessage(msg, adapter, true);
} }
log.warn(`Approval recovery failed: ${convResult.details}`); log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`);
return this.processMessage(msg, adapter, true); }
// Fall back to API-level recovery
this.sessionManager.invalidateSession(retryConvKey);
session = null;
const result = (retryConvId && isRecoverableConversationId(retryConvId))
? await recoverOrphanedConversationApproval(this.store.agentId, retryConvId, true)
: await recoverPendingApprovalsForAgent(this.store.agentId);
if (result.recovered) {
log.info(`API-level recovery succeeded (${result.details}), retrying message...`);
} else { } else {
log.info('Approval conflict in default conversation -- attempting agent-level recovery...'); log.warn(`API-level recovery failed: ${result.details}`);
this.sessionManager.invalidateSession(retryConvKey);
session = null;
clearInterval(typingInterval);
const agentResult = await recoverPendingApprovalsForAgent(this.store.agentId);
if (agentResult.recovered) {
log.info(`Agent-level recovery succeeded (${agentResult.details}), retrying message...`);
return this.processMessage(msg, adapter, true);
} }
log.warn(`Agent-level recovery failed: ${agentResult.details}`);
return this.processMessage(msg, adapter, true); return this.processMessage(msg, adapter, true);
} }
}
// Empty/error result retry // Empty/error result retry
if (retryDecision.shouldRetryForEmptyResult || retryDecision.shouldRetryForErrorResult) { if (retryDecision.shouldRetryForEmptyResult || retryDecision.shouldRetryForErrorResult) {
@@ -1830,7 +1830,7 @@ export class LettaBot implements AgentSession {
let retried = false; let retried = false;
while (true) { while (true) {
const { stream } = await this.sessionManager.runSession(text, { convKey, retried }); const { session, stream } = await this.sessionManager.runSession(text, { convKey, retried });
try { try {
let response = ''; let response = '';
@@ -1885,15 +1885,21 @@ export class LettaBot implements AgentSession {
|| ((lastErrorDetail?.message?.toLowerCase().includes('conflict') || false) || ((lastErrorDetail?.message?.toLowerCase().includes('conflict') || false)
&& (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false)); && (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false));
if (isApprovalIssue && !retried) { if (isApprovalIssue && !retried) {
log.info('sendToAgent: approval conflict detected -- attempting SDK recovery...');
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 });
if (sdkResult.recovered) {
log.info('sendToAgent: SDK approval recovery succeeded');
} else {
log.warn(`sendToAgent: SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`);
if (this.store.agentId) { if (this.store.agentId) {
const recovery = await recoverPendingApprovalsForAgent(this.store.agentId); const recovery = await recoverPendingApprovalsForAgent(this.store.agentId);
if (recovery.recovered) { if (recovery.recovered) {
log.info(`sendToAgent: agent-level approval recovery succeeded (${recovery.details})`); log.info(`sendToAgent: API-level recovery succeeded (${recovery.details})`);
} else { } else {
log.warn(`sendToAgent: agent-level approval recovery did not resolve approvals (${recovery.details})`); log.warn(`sendToAgent: API-level recovery failed (${recovery.details})`);
}
} }
} }
log.info('sendToAgent: approval issue detected -- retrying once with fresh session...');
this.sessionManager.invalidateSession(convKey); this.sessionManager.invalidateSession(convKey);
retried = true; retried = true;
approvalRetryPending = true; approvalRetryPending = true;

View File

@@ -110,6 +110,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -147,6 +148,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -176,6 +178,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -211,6 +214,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -241,6 +245,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-stale', conversationId: 'conv-stale',
}; };
@@ -255,6 +260,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-recreated', agentId: 'agent-recreated',
conversationId: 'conv-recreated', conversationId: 'conv-recreated',
}; };
@@ -293,6 +299,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-keep', conversationId: 'conv-keep',
}; };
@@ -331,6 +338,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test-1', conversationId: 'conversation-contract-test-1',
}; };
@@ -346,6 +354,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test-2', conversationId: 'conversation-contract-test-2',
}; };
@@ -378,6 +387,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-old', conversationId: 'conv-old',
}; };
@@ -392,6 +402,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-new', conversationId: 'conv-new',
}; };
@@ -453,6 +464,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'default', conversationId: 'default',
}; };
@@ -468,6 +480,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'default', conversationId: 'default',
}; };
@@ -506,6 +519,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-stuck', conversationId: 'conv-stuck',
}; };
@@ -521,6 +535,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-fresh', conversationId: 'conv-fresh',
}; };
@@ -567,6 +582,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -596,6 +612,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -625,6 +642,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -654,6 +672,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -692,6 +711,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -754,6 +774,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: `${sessionName}-conversation`, conversationId: `${sessionName}-conversation`,
} as never; } as never;
@@ -872,6 +893,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-new', conversationId: 'conv-new',
}; };
@@ -879,9 +901,11 @@ describe('SDK session contract', () => {
const activeSession = { const activeSession = {
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
}; };
const idleSession = { const idleSession = {
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
}; };
const bot = new LettaBot({ const bot = new LettaBot({
@@ -920,6 +944,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -946,6 +971,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-123', conversationId: 'conv-123',
}; };
@@ -978,6 +1004,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test', conversationId: 'conversation-contract-test',
}; };
@@ -1012,6 +1039,7 @@ describe('SDK session contract', () => {
})(); })();
}), }),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-approval', conversationId: 'conv-approval',
}; };
@@ -1042,7 +1070,7 @@ describe('SDK session contract', () => {
let runCall = 0; let runCall = 0;
(bot as any).sessionManager.runSession = vi.fn(async () => ({ (bot as any).sessionManager.runSession = vi.fn(async () => ({
session: { abort: vi.fn(async () => undefined) }, session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) },
stream: async function* () { stream: async function* () {
if (runCall++ === 0) { if (runCall++ === 0) {
yield { type: 'result', success: false, error: 'error', conversationId: 'conv-approval' }; yield { type: 'result', success: false, error: 'error', conversationId: 'conv-approval' };
@@ -1108,7 +1136,7 @@ describe('SDK session contract', () => {
let runCall = 0; let runCall = 0;
(bot as any).sessionManager.runSession = vi.fn(async () => ({ (bot as any).sessionManager.runSession = vi.fn(async () => ({
session: { abort: vi.fn(async () => undefined) }, session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) },
stream: async function* () { stream: async function* () {
if (runCall++ === 0) { if (runCall++ === 0) {
// Pre-foreground error is filtered by the pipeline -- it never // Pre-foreground error is filtered by the pipeline -- it never
@@ -1183,7 +1211,7 @@ describe('SDK session contract', () => {
let runCall = 0; let runCall = 0;
(bot as any).sessionManager.runSession = vi.fn(async () => ({ (bot as any).sessionManager.runSession = vi.fn(async () => ({
session: { abort: vi.fn(async () => undefined) }, session: { abort: vi.fn(async () => undefined), recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })) },
stream: async function* () { stream: async function* () {
if (runCall++ === 0) { if (runCall++ === 0) {
yield { type: 'result', success: false, error: 'error', conversationId: 'default' }; yield { type: 'result', success: false, error: 'error', conversationId: 'default' };
@@ -1255,6 +1283,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-stuck', conversationId: 'conv-stuck',
}; };
@@ -1270,6 +1299,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-contract-test', agentId: 'agent-contract-test',
conversationId: 'conv-fresh', conversationId: 'conv-fresh',
}; };
@@ -1316,6 +1346,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-new-tagged', agentId: 'agent-new-tagged',
conversationId: 'conversation-new-tagged', conversationId: 'conversation-new-tagged',
}; };
@@ -1371,6 +1402,7 @@ describe('SDK session contract', () => {
})(); })();
}), }),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-runid-test', agentId: 'agent-runid-test',
conversationId: 'conversation-runid-test', conversationId: 'conversation-runid-test',
}; };
@@ -1409,6 +1441,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-reuse-false', agentId: 'agent-reuse-false',
conversationId: 'conversation-reuse-false', conversationId: 'conversation-reuse-false',
}; };
@@ -1442,6 +1475,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-background-directives', agentId: 'agent-background-directives',
conversationId: 'conversation-background-directives', conversationId: 'conversation-background-directives',
}; };
@@ -1528,6 +1562,7 @@ describe('SDK session contract', () => {
})() })()
), ),
close: vi.fn(() => undefined), close: vi.fn(() => undefined),
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
agentId: 'agent-queue-leak-test', agentId: 'agent-queue-leak-test',
conversationId: 'conversation-queue-leak-test', conversationId: 'conversation-queue-leak-test',
}; };

View File

@@ -373,35 +373,28 @@ export class SessionManager {
); );
if (bootstrap.hasPendingApproval) { if (bootstrap.hasPendingApproval) {
const convId = bootstrap.conversationId || session.conversationId; const convId = bootstrap.conversationId || session.conversationId;
if (!isRecoverableConversationId(convId)) {
log.warn(
`Pending approval detected at session startup (key=${key}, conv=${convId}) ` +
'using agent-level recovery fallback.'
);
session.close();
const result = await recoverPendingApprovalsForAgent(this.store.agentId);
if (result.recovered) {
log.info(`Proactive agent-level recovery succeeded: ${result.details}`);
} else {
log.warn(`Proactive agent-level recovery did not resolve approvals: ${result.details}`);
}
return this._createSessionForKey(key, true, generation);
} else {
log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`); log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`);
// Try SDK-level recovery first (goes through CLI control protocol)
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 });
if (sdkResult.recovered) {
log.info('Proactive SDK approval recovery succeeded');
return this._createSessionForKey(key, true, generation);
}
// SDK recovery failed -- fall back to API-level recovery
log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`);
session.close(); session.close();
const result = await recoverOrphanedConversationApproval( const result = isRecoverableConversationId(convId)
this.store.agentId, ? await recoverOrphanedConversationApproval(this.store.agentId, convId, true)
convId, : await recoverPendingApprovalsForAgent(this.store.agentId);
true, /* deepScan */
);
if (result.recovered) { if (result.recovered) {
log.info(`Proactive approval recovery succeeded: ${result.details}`); log.info(`Proactive API-level recovery succeeded: ${result.details}`);
} else { } else {
log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`); log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`);
} }
return this._createSessionForKey(key, true, generation); return this._createSessionForKey(key, true, generation);
} }
}
} catch (err) { } catch (err) {
// bootstrapState failure is non-fatal -- the reactive 409 handler in // bootstrapState failure is non-fatal -- the reactive 409 handler in
// runSession() will catch stuck approvals. // runSession() will catch stuck approvals.
@@ -578,18 +571,25 @@ export class SessionManager {
try { try {
await this.withSessionTimeout(session.send(message), `Session send (key=${convKey})`); await this.withSessionTimeout(session.send(message), `Session send (key=${convKey})`);
} catch (error) { } catch (error) {
// 409 CONFLICT from orphaned approval // 409 CONFLICT from orphaned approval -- use SDK recovery first, fall back to API
if (!retried && isApprovalConflictError(error) && this.store.agentId) { if (!retried && isApprovalConflictError(error) && this.store.agentId) {
log.info('CONFLICT detected - attempting orphaned approval recovery...'); log.info('CONFLICT detected - attempting SDK approval recovery...');
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 });
if (sdkResult.recovered) {
log.info('SDK approval recovery succeeded, retrying...');
return this.runSession(message, { retried: true, canUseTool, convKey });
}
// SDK recovery failed or unsupported -- fall back to API-level recovery
log.warn(`SDK recovery did not resolve (${sdkResult.detail ?? 'unknown'}), trying API-level recovery...`);
this.invalidateSession(convKey); this.invalidateSession(convKey);
const result = isRecoverableConversationId(convId) const result = isRecoverableConversationId(convId)
? await recoverOrphanedConversationApproval(this.store.agentId, convId) ? await recoverOrphanedConversationApproval(this.store.agentId, convId)
: await recoverPendingApprovalsForAgent(this.store.agentId); : await recoverPendingApprovalsForAgent(this.store.agentId);
if (result.recovered) { if (result.recovered) {
log.info(`Recovery succeeded (${result.details}), retrying...`); log.info(`API-level recovery succeeded (${result.details}), retrying...`);
return this.runSession(message, { retried: true, canUseTool, convKey }); return this.runSession(message, { retried: true, canUseTool, convKey });
} }
log.error(`Orphaned approval recovery failed: ${result.details}`); log.error(`Approval recovery failed: ${result.details}`);
throw error; throw error;
} }