diff --git a/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts b/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts index 2ea87f61..fabff061 100644 --- a/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts +++ b/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts @@ -187,6 +187,62 @@ async function send(platform: Platform, text: string, options: { unauthorized?: for (const platform of platforms) { describe(`${platform} actual module session selection`, () => { + it.each(['/status', '/stop', '/clear'])('preserves the binding when %s cannot reconnect and retries the command', async (command) => { + const adapter = adapterFor(platform) + const chatId = chatFor(platform) + const originalProject = path.join(temporaryRoot, `${platform}-command-project`) + fs.mkdirSync(originalProject, { recursive: true }) + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + adapter.sessionStore.set(chatId, 'old-cmd', originalProject) + const originalBinding = adapter.sessionStore.get(chatId) + const creationsBefore = newSessionCount + const noticesBefore = notices.length + const failedOpen = spyOn(adapter.bridge, 'waitForOpen').mockImplementationOnce(async () => { + adapter.bridge.resetSession(chatId) + return false + }) + const sendSpy = spyOn(adapter.bridge, 'sendUserMessage') + const stopSpy = spyOn(adapter.bridge, 'sendStopGeneration') + try { + await send(platform, command) + + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + expect(sendSpy).not.toHaveBeenCalled() + expect(stopSpy).not.toHaveBeenCalled() + expect(notices).toHaveLength(noticesBefore + 1) + expect(notices.at(-1)).toContain('已保留会话和工作目录') + expect(notices.at(-1)).toContain('重试') + expect(notices.at(-1)).not.toContain('当前没有活动会话') + + await send(platform, command) + + expect(adapter.bridge.getSessionId(chatId)).toBe('old-cmd') + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + if (command === '/clear') { + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(chatId, '/clear') + expect(stopSpy).not.toHaveBeenCalled() + } else if (command === '/stop') { + expect(stopSpy).toHaveBeenCalledTimes(1) + expect(stopSpy).toHaveBeenCalledWith(chatId) + expect(sendSpy).not.toHaveBeenCalled() + } else { + expect(notices.at(-1)).toContain('old-cmd') + expect(sendSpy).not.toHaveBeenCalled() + expect(stopSpy).not.toHaveBeenCalled() + } + } finally { + failedOpen.mockRestore() + sendSpy.mockRestore() + stopSpy.mockRestore() + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + } + }) + it('keeps the original session and project after reconnect timeout and resumes it on retry', async () => { const adapter = adapterFor(platform) const chatId = chatFor(platform) diff --git a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts index f659ee99..ab728c1f 100644 --- a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts +++ b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts @@ -177,6 +177,52 @@ describe('Telegram entrypoint session routing', () => { } }) + it.each(['status', 'stop', 'clear'] as const)('preserves the session and project when /%s cannot reconnect, then retries the original session', async (command) => { + const chatId = nextId++ + const sessionId = `retry-${command}` + sessionPaths.set(sessionId, worktree) + store.set(String(chatId), sessionId, worktree) + const originalBinding = store.get(String(chatId)) + const creationsBefore = requests.filter((request) => request === 'POST /api/sessions').length + const sendMessage = spyOn(WsBridge.prototype, 'sendUserMessage') + const sendStop = spyOn(WsBridge.prototype, 'sendStopGeneration') + const failedOpen = spyOn(WsBridge.prototype, 'waitForOpen').mockImplementationOnce(async function (this: WsBridge, id) { + this.resetSession(id) + return false + }) + try { + await text(chatId, `/${command}`) + // /stop and /clear dispatch asynchronously, so wait for their failure reply. + await eventually(() => expect(texts(chatId).at(-1)).toContain('已保留会话和工作目录')) + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + expect(sendMessage).not.toHaveBeenCalled() + expect(sendStop).not.toHaveBeenCalled() + expect(messages.filter((item) => item.sessionId === sessionId)).toEqual([]) + expect(texts(chatId).at(-1)).not.toContain('/new') + + await text(chatId, `/${command}`) + await eventually(() => { + if (command === 'status') { + expect(texts(chatId).at(-1)).toContain(sessionId) + } else if (command === 'stop') { + expect(messages.some((item) => item.sessionId === sessionId && item.message.type === 'stop_generation')).toBe(true) + expect(texts(chatId).at(-1)).toContain('已发送停止信号') + } else { + expect(messages.some((item) => item.sessionId === sessionId && item.message.content === '/clear')).toBe(true) + expect(texts(chatId).some((value) => value.includes('已清空当前会话上下文'))).toBe(true) + } + }) + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + expect(failedOpen).toHaveBeenCalledTimes(2) + } finally { + failedOpen.mockRestore() + sendMessage.mockRestore() + sendStop.mockRestore() + } + }) + it('runs registered history commands after authorization and deduplication', async () => { const before = requests.length await text(701, '/sessions', { userId: 99 })