From 7c5f6eaf63b50bda507d314ef1e395469d938287 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 2 Mar 2026 11:37:47 -0800 Subject: [PATCH] fix: handle 'default' conversationId and use resumeSession for default conversation (#452) --- src/core/bot.ts | 6 ++- src/core/sdk-session-contract.test.ts | 55 +++++++++++++-------------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 4b9dd88..73e0f3b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -624,10 +624,11 @@ export class LettaBot implements AgentSession { } session = resumeSession(convId, opts); } else if (this.store.agentId) { + // Agent exists but no conversation stored -- resume the default conversation process.env.LETTA_AGENT_ID = this.store.agentId; installSkillsToAgent(this.store.agentId, this.config.skills); sessionAgentId = this.store.agentId; - session = createSession(this.store.agentId, opts); + session = resumeSession(this.store.agentId, opts); } else { // Create new agent -- persist immediately so we don't orphan it on later failures log.info('Creating new agent'); @@ -842,8 +843,9 @@ export class LettaBot implements AgentSession { const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); log.info('Agent ID updated:', session.agentId); - } else if (session.conversationId) { + } else if (session.conversationId && session.conversationId !== 'default') { // In per-channel mode, persist per-key. In shared mode, use legacy field. + // Skip saving "default" -- it's an API alias, not a real conversation ID. if (convKey && convKey !== 'shared') { const existing = this.store.getConversationId(convKey); if (session.conversationId !== existing) { diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index c505da6..fd740f2 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -102,7 +102,6 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); vi.mocked(resumeSession).mockReturnValue(mockSession as never); const bot = new LettaBot({ @@ -113,8 +112,8 @@ describe('SDK session contract', () => { await bot.sendToAgent('first message'); await bot.sendToAgent('second message'); - expect(vi.mocked(resumeSession)).not.toHaveBeenCalled(); - expect(vi.mocked(createSession)).toHaveBeenCalledTimes(1); + expect(vi.mocked(createSession)).not.toHaveBeenCalled(); + expect(vi.mocked(resumeSession)).toHaveBeenCalledTimes(1); expect(mockSession.initialize).toHaveBeenCalledTimes(1); expect(mockSession.send).toHaveBeenCalledTimes(2); expect(mockSession.send).toHaveBeenNthCalledWith(1, 'first message'); @@ -219,9 +218,10 @@ describe('SDK session contract', () => { }; vi.mocked(createAgent).mockResolvedValue('agent-recreated'); - vi.mocked(createSession) - .mockReturnValueOnce(staleSession as never) - .mockReturnValueOnce(recoveredSession as never); + // First call: agentId exists, no convId → resumeSession(agentId) + vi.mocked(resumeSession).mockReturnValueOnce(staleSession as never); + // After clearAgent + createAgent → createSession(newAgentId) + vi.mocked(createSession).mockReturnValueOnce(recoveredSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -234,9 +234,8 @@ describe('SDK session contract', () => { expect(response).toBe('fresh response'); expect(staleSession.close).toHaveBeenCalledTimes(1); expect(vi.mocked(createAgent)).toHaveBeenCalledTimes(1); - expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2); - expect(vi.mocked(createSession).mock.calls[0][0]).toBe('agent-contract-test'); - expect(vi.mocked(createSession).mock.calls[1][0]).toBe('agent-recreated'); + expect(vi.mocked(resumeSession).mock.calls[0][0]).toBe('agent-contract-test'); + expect(vi.mocked(createSession).mock.calls[0][0]).toBe('agent-recreated'); }); it('does not clear agent state on generic initialize failures', async () => { @@ -309,10 +308,9 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test-2', }; - vi.mocked(createSession) + vi.mocked(resumeSession) .mockReturnValueOnce(firstSession as never) .mockReturnValueOnce(secondSession as never); - vi.mocked(resumeSession).mockReturnValue(firstSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -322,7 +320,7 @@ describe('SDK session contract', () => { await expect(bot.sendToAgent('trigger fallback')).rejects.toThrow('network down'); expect(firstSession.close).toHaveBeenCalledTimes(1); expect(secondSession.close).toHaveBeenCalledTimes(1); - expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2); + expect(vi.mocked(resumeSession)).toHaveBeenCalledTimes(2); }); it('reset ignores stale in-flight warm session and creates a fresh one', async () => { @@ -356,8 +354,9 @@ describe('SDK session contract', () => { conversationId: 'conv-new', }; - vi.mocked(resumeSession).mockReturnValue(warmSession as never); - vi.mocked(createSession).mockReturnValue(resetSession as never); + vi.mocked(resumeSession) + .mockReturnValueOnce(warmSession as never) + .mockReturnValueOnce(resetSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -384,7 +383,7 @@ describe('SDK session contract', () => { expect(resetMessage).toContain('New conversation: conv-new'); expect(warmSession.close).toHaveBeenCalledTimes(1); expect(resetSession.initialize).toHaveBeenCalledTimes(1); - expect(vi.mocked(createSession)).toHaveBeenCalledTimes(1); + expect(vi.mocked(resumeSession)).toHaveBeenCalledTimes(2); }); it('does not pre-warm a shared session in per-chat mode', async () => { @@ -401,7 +400,7 @@ describe('SDK session contract', () => { expect(vi.mocked(resumeSession)).not.toHaveBeenCalled(); }); - it('passes memfs: true to createSession when config sets memfs true', async () => { + it('passes memfs: true to resumeSession when config sets memfs true', async () => { const mockSession = { initialize: vi.fn(async () => undefined), send: vi.fn(async (_message: unknown) => undefined), @@ -416,7 +415,7 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -426,11 +425,11 @@ describe('SDK session contract', () => { await bot.sendToAgent('test'); - const opts = vi.mocked(createSession).mock.calls[0][1]; + const opts = vi.mocked(resumeSession).mock.calls[0][1]; expect(opts).toHaveProperty('memfs', true); }); - it('passes memfs: false to createSession when config sets memfs false', async () => { + it('passes memfs: false to resumeSession when config sets memfs false', async () => { const mockSession = { initialize: vi.fn(async () => undefined), send: vi.fn(async (_message: unknown) => undefined), @@ -445,7 +444,7 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -455,11 +454,11 @@ describe('SDK session contract', () => { await bot.sendToAgent('test'); - const opts = vi.mocked(createSession).mock.calls[0][1]; + const opts = vi.mocked(resumeSession).mock.calls[0][1]; expect(opts).toHaveProperty('memfs', false); }); - it('omits memfs key from createSession options when config memfs is undefined', async () => { + it('omits memfs key from resumeSession options when config memfs is undefined', async () => { const mockSession = { initialize: vi.fn(async () => undefined), send: vi.fn(async (_message: unknown) => undefined), @@ -474,7 +473,7 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -484,7 +483,7 @@ describe('SDK session contract', () => { await bot.sendToAgent('test'); - const opts = vi.mocked(createSession).mock.calls[0][1]; + const opts = vi.mocked(resumeSession).mock.calls[0][1]; expect(opts).not.toHaveProperty('memfs'); }); @@ -530,7 +529,7 @@ describe('SDK session contract', () => { agentId: 'agent-contract-test', conversationId: 'conv-new', }; - vi.mocked(createSession).mockReturnValue(createdSession as never); + vi.mocked(resumeSession).mockReturnValue(createdSession as never); const activeSession = { close: vi.fn(() => undefined), @@ -578,7 +577,7 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); const bot = new LettaBot({ workingDir: join(dataDir, 'working'), @@ -604,7 +603,7 @@ describe('SDK session contract', () => { conversationId: 'conv-123', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); vi.mocked(getLatestRunError).mockResolvedValueOnce({ message: 'INTERNAL_SERVER_ERROR: Bad request to Anthropic: Error code: 400', stopReason: 'llm_api_error', @@ -636,7 +635,7 @@ describe('SDK session contract', () => { conversationId: 'conversation-contract-test', }; - vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(resumeSession).mockReturnValue(mockSession as never); vi.mocked(getLatestRunError).mockResolvedValueOnce(null); const bot = new LettaBot({