-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(base): persist channel sessions across restarts #3865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
77d73d0
c4cf65e
9573868
2e3a524
cb02426
1743540
c832ca5
06036bd
ac56d92
04f24d1
7e40926
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -217,6 +217,67 @@ describe('SessionRouter', () => { | |
| expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); | ||
| expect(router.getAll()).toEqual([]); | ||
| }); | ||
|
|
||
| it('preserves persist file for restoration on next start', async () => { | ||
| const { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } = | ||
| await import('node:fs'); | ||
| const { join } = await import('node:path'); | ||
| const tmpDir = join('/tmp', `test-cleara11-persist-${Date.now()}`); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Nit] 4 个新测试都用 import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const tmpDir = mkdtempSync(join(tmpdir(), 'test-restore-'));顺便:本行的目录前缀写成了 |
||
| mkdirSync(tmpDir, { recursive: true }); | ||
|
|
||
| const persistFile = join(tmpDir, 'sessions.json'); | ||
| const originalEntries = { | ||
| 'telegram:alice:chat1': { | ||
| sessionId: 'session-xyz', | ||
| target: { | ||
| channelName: 'telegram', | ||
| senderId: 'alice', | ||
| chatId: 'chat1', | ||
| }, | ||
| cwd: '/workspace', | ||
| }, | ||
| }; | ||
|
|
||
| try { | ||
| // Create a router, write a session, then clearAll | ||
| const router = new SessionRouter(bridge, '/tmp', 'user', persistFile); | ||
| await router.resolve('telegram', 'alice', 'chat1'); | ||
|
|
||
| // Populate the persist file with known data | ||
| writeFileSync(persistFile, JSON.stringify(originalEntries, null, 2)); | ||
|
|
||
| router.clearAll(); | ||
|
|
||
| // Memory should be cleared | ||
| expect(router.hasSession('telegram', 'alice', 'chat1')).toBe(false); | ||
| expect(router.getAll()).toEqual([]); | ||
|
|
||
| // Persist file should still exist with original content | ||
| expect(existsSync(persistFile)).toBe(true); | ||
| const onDisk = JSON.parse(readFileSync(persistFile, 'utf-8')); | ||
| expect(onDisk).toEqual(originalEntries); | ||
|
|
||
| // A fresh router should be able to restore from it | ||
| const freshRouter = new SessionRouter( | ||
| bridge, | ||
| '/tmp', | ||
| 'user', | ||
| persistFile, | ||
| ); | ||
| const result = await freshRouter.restoreSessions(); | ||
|
|
||
| expect(result.restored).toBe(1); | ||
| expect(result.failed).toBe(0); | ||
| expect(freshRouter.hasSession('telegram', 'alice', 'chat1')).toBe(true); | ||
| expect(freshRouter.getTarget('session-xyz')).toEqual({ | ||
| channelName: 'telegram', | ||
| senderId: 'alice', | ||
| chatId: 'chat1', | ||
| }); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('setBridge', () => { | ||
|
|
@@ -229,4 +290,144 @@ describe('SessionRouter', () => { | |
| expect(bridge.newSession).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] — deepseek-v4-pro via Qwen Code /review |
||
| describe('restoreSessions', () => { | ||
| it('restores sessions from persist file', async () => { | ||
| const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); | ||
| const { join } = await import('node:path'); | ||
| const tmpDir = join('/tmp', `test-restore-${Date.now()}`); | ||
| mkdirSync(tmpDir, { recursive: true }); | ||
|
|
||
| try { | ||
| const persistFile = join(tmpDir, 'sessions.json'); | ||
| const entries = { | ||
| 'telegram:alice:chat1': { | ||
| sessionId: 'old-session-1', | ||
| target: { | ||
| channelName: 'telegram', | ||
| senderId: 'alice', | ||
| chatId: 'chat1', | ||
| }, | ||
| cwd: '/workspace', | ||
| }, | ||
| 'telegram:bob:chat2': { | ||
| sessionId: 'old-session-2', | ||
| target: { | ||
| channelName: 'telegram', | ||
| senderId: 'bob', | ||
| chatId: 'chat2', | ||
| }, | ||
| cwd: '/workspace', | ||
| }, | ||
| }; | ||
| writeFileSync(persistFile, JSON.stringify(entries, null, 2)); | ||
|
|
||
| const router = new SessionRouter(bridge, '/tmp', 'user', persistFile); | ||
| const result = await router.restoreSessions(); | ||
|
|
||
| expect(result.restored).toBe(2); | ||
| expect(result.failed).toBe(0); | ||
| expect(bridge.loadSession).toHaveBeenCalledTimes(2); | ||
| expect(router.hasSession('telegram', 'alice', 'chat1')).toBe(true); | ||
| expect(router.hasSession('telegram', 'bob', 'chat2')).toBe(true); | ||
|
|
||
| const target = router.getTarget('old-session-1'); | ||
| expect(target).toEqual({ | ||
| channelName: 'telegram', | ||
| senderId: 'alice', | ||
| chatId: 'chat1', | ||
| }); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true }); | ||
| } | ||
| }); | ||
|
|
||
| it('skips entries whose loadSession throws', async () => { | ||
| const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); | ||
| const { join } = await import('node:path'); | ||
| const tmpDir = join('/tmp', `test-restore-fail-${Date.now()}`); | ||
| mkdirSync(tmpDir, { recursive: true }); | ||
|
|
||
| try { | ||
| const persistFile = join(tmpDir, 'sessions.json'); | ||
| const entries = { | ||
| 'telegram:alice:chat1': { | ||
| sessionId: 'good-session', | ||
| target: { | ||
| channelName: 'telegram', | ||
| senderId: 'alice', | ||
| chatId: 'chat1', | ||
| }, | ||
| cwd: '/workspace', | ||
| }, | ||
| 'telegram:bob:chat2': { | ||
| sessionId: 'stale-session', | ||
| target: { | ||
| channelName: 'telegram', | ||
| senderId: 'bob', | ||
| chatId: 'chat2', | ||
| }, | ||
| cwd: '/workspace', | ||
| }, | ||
| }; | ||
| writeFileSync(persistFile, JSON.stringify(entries, null, 2)); | ||
|
|
||
| // Make loadSession throw for the stale session | ||
| (bridge.loadSession as ReturnType<typeof vi.fn>).mockImplementation( | ||
| (id: string) => { | ||
| if (id === 'stale-session') { | ||
| throw new Error('Session not found'); | ||
| } | ||
| return id; | ||
| }, | ||
| ); | ||
|
|
||
| const router = new SessionRouter(bridge, '/tmp', 'user', persistFile); | ||
| const result = await router.restoreSessions(); | ||
|
|
||
| expect(result.restored).toBe(1); | ||
| expect(result.failed).toBe(1); | ||
| expect(router.hasSession('telegram', 'alice', 'chat1')).toBe(true); | ||
| expect(router.hasSession('telegram', 'bob', 'chat2')).toBe(false); | ||
|
|
||
| // Persist file should be updated to remove the failed entry | ||
| const { readFileSync } = await import('node:fs'); | ||
| const updated = JSON.parse(readFileSync(persistFile, 'utf-8')); | ||
| expect(Object.keys(updated)).toEqual(['telegram:alice:chat1']); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true }); | ||
| } | ||
| }); | ||
|
|
||
| it('returns zeros when no persist file exists', async () => { | ||
| const router = new SessionRouter( | ||
| bridge, | ||
| '/tmp', | ||
| 'user', | ||
| '/nonexistent/sessions.json', | ||
| ); | ||
| const result = await router.restoreSessions(); | ||
| expect(result).toEqual({ restored: 0, failed: 0 }); | ||
| }); | ||
|
|
||
| it('returns zeros when persist file is empty', async () => { | ||
| const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); | ||
| const { join } = await import('node:path'); | ||
| const tmpDir = join('/tmp', `test-restore-empty-${Date.now()}`); | ||
| mkdirSync(tmpDir, { recursive: true }); | ||
|
|
||
| try { | ||
| const persistFile = join(tmpDir, 'sessions.json'); | ||
| writeFileSync(persistFile, '{}'); | ||
|
|
||
| const router = new SessionRouter(bridge, '/tmp', 'user', persistFile); | ||
| const result = await router.restoreSessions(); | ||
|
|
||
| expect(result).toEqual({ restored: 0, failed: 0 }); | ||
| expect(bridge.loadSession).not.toHaveBeenCalled(); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true }); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; | ||
| import { existsSync, readFileSync, writeFileSync } from 'node:fs'; | ||
| import type { SessionScope, SessionTarget } from './types.js'; | ||
| import type { AcpBridge } from './AcpBridge.js'; | ||
|
|
||
|
|
@@ -196,18 +196,12 @@ export class SessionRouter { | |
| return { restored, failed }; | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] 新加的 可考虑:
若不想现在改,建议至少留 TODO 标注,避免后续遗忘。 |
||
| /** Clear in-memory state and delete persist file. Used on clean shutdown. */ | ||
| /** Clear in-memory state. Persist file is left intact for the next start | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Please add a regression test that writes a session through a router with a real — gpt-5.5 via Qwen Code /review |
||
| * via {@link restoreSessions}. */ | ||
| clearAll(): void { | ||
| this.toSession.clear(); | ||
| this.toTarget.clear(); | ||
| this.toCwd.clear(); | ||
| if (this.persistPath && existsSync(this.persistPath)) { | ||
| try { | ||
| unlinkSync(this.persistPath); | ||
| } catch { | ||
| // best-effort | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private persist(): void { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -92,6 +92,11 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ | |||||||||
| _args: args, | ||||||||||
| })), | ||||||||||
| SessionService: vi.fn(), | ||||||||||
| Storage: { | ||||||||||
| runWithRuntimeBaseDir: vi.fn( | ||||||||||
| (_dir: unknown, _cwd: unknown, fn: () => unknown) => fn(), | ||||||||||
| ), | ||||||||||
| }, | ||||||||||
| tokenLimit: vi.fn(), | ||||||||||
| SessionStartSource: { | ||||||||||
| Startup: 'startup', | ||||||||||
|
|
@@ -126,7 +131,11 @@ import { | |||||||||
| import type { Config } from '@qwen-code/qwen-code-core'; | ||||||||||
| import type { LoadedSettings } from '../config/settings.js'; | ||||||||||
| import type { CliArgs } from '../config/config.js'; | ||||||||||
| import { SessionEndReason, MCPServerConfig } from '@qwen-code/qwen-code-core'; | ||||||||||
| import { | ||||||||||
| SessionEndReason, | ||||||||||
| MCPServerConfig, | ||||||||||
| SessionService, | ||||||||||
| } from '@qwen-code/qwen-code-core'; | ||||||||||
| import type { McpServer } from '@agentclientprotocol/sdk'; | ||||||||||
| import { AgentSideConnection } from '@agentclientprotocol/sdk'; | ||||||||||
| import { loadSettings } from '../config/settings.js'; | ||||||||||
|
|
@@ -631,6 +640,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { | |||||||||
| type AgentLike = { | ||||||||||
| initialize: (args: Record<string, unknown>) => Promise<unknown>; | ||||||||||
| newSession: (args: Record<string, unknown>) => Promise<unknown>; | ||||||||||
| loadSession: (args: Record<string, unknown>) => Promise<unknown>; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| let mockConfig: Config; | ||||||||||
|
|
@@ -893,4 +903,52 @@ describe('QwenAgent MCP SSE/HTTP support', () => { | |||||||||
| mockConnectionState.resolve(); | ||||||||||
| await agentPromise; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] — deepseek-v4-pro via Qwen Code /review |
||||||||||
| it('loadSession rejects when sessionExists returns false, skipping config load', async () => { | ||||||||||
| // Save the default SessionService mock and override for this test | ||||||||||
| const savedImpl = vi.mocked(SessionService).getMockImplementation(); | ||||||||||
| const sessionExistsStub = vi.fn().mockResolvedValue(false); | ||||||||||
| vi.mocked(SessionService).mockImplementation( | ||||||||||
| () => | ||||||||||
| ({ | ||||||||||
| sessionExists: sessionExistsStub, | ||||||||||
| }) as unknown as InstanceType<typeof SessionService>, | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| try { | ||||||||||
| await setupSessionMocks('session-nonexistent'); | ||||||||||
|
|
||||||||||
| const agentPromise = runAcpAgent( | ||||||||||
| mockConfig, | ||||||||||
| makeSessionSettings(), | ||||||||||
| mockArgv, | ||||||||||
| ); | ||||||||||
| await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); | ||||||||||
|
|
||||||||||
| const agent = capturedAgentFactory!({ | ||||||||||
| get closed() { | ||||||||||
| return mockConnectionState.promise; | ||||||||||
| }, | ||||||||||
| }) as AgentLike; | ||||||||||
|
|
||||||||||
| await expect( | ||||||||||
| agent.loadSession({ | ||||||||||
| cwd: '/tmp', | ||||||||||
| sessionId: 'nonexistent', | ||||||||||
| }), | ||||||||||
| ).rejects.toThrow('Session nonexistent does not exist at /tmp'); | ||||||||||
|
|
||||||||||
| // Verify sessionExists was queried for the correct session | ||||||||||
| expect(sessionExistsStub).toHaveBeenCalledWith('nonexistent'); | ||||||||||
|
|
||||||||||
| mockConnectionState.resolve(); | ||||||||||
| await agentPromise; | ||||||||||
| } finally { | ||||||||||
| // Restore the default mock to avoid polluting subsequent tests | ||||||||||
| vi.mocked(SessionService).mockImplementation( | ||||||||||
| savedImpl as typeof vi.mocked<typeof SessionService>, | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Type error TS2345/TS2352 —
Suggested change
— deepseek-v4-pro via Qwen Code /review |
||||||||||
| ); | ||||||||||
| // Also let beforeEach clearAllMocks handle the sessionExists stub | ||||||||||
| } | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -293,6 +293,12 @@ class QwenAgent implements Agent { | |
| }, | ||
| ); | ||
|
|
||
| if (!exists) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new Please add an ACP agent test that mocks — gpt-5.5 via Qwen Code /review |
||
| throw new Error( | ||
| `Session ${params.sessionId} does not exist at ${params.cwd}`, | ||
| ); | ||
| } | ||
|
|
||
| const config = await this.newSessionConfig( | ||
| params.cwd, | ||
| params.mcpServers, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -57,6 +57,16 @@ function sessionsPath(): string { | |||||||||||||||||||||||
| return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json'); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function restoreAndLogSessions(router: SessionRouter): Promise<void> { | ||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The helper returns
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||
| const result = await router.restoreSessions(); | ||||||||||||||||||||||||
| if (result.restored > 0 || result.failed > 0) { | ||||||||||||||||||||||||
| writeStdoutLine( | ||||||||||||||||||||||||
| `[Channel] Sessions restored: ${result.restored}` + | ||||||||||||||||||||||||
| (result.failed > 0 ? `, failed: ${result.failed}` : ''), | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function loadChannelsConfig(): Record<string, unknown> { | ||||||||||||||||||||||||
| const settings = loadSettings(process.cwd()); | ||||||||||||||||||||||||
| const channels = ( | ||||||||||||||||||||||||
|
|
@@ -215,6 +225,9 @@ async function startSingle(name: string, proxy?: string): Promise<void> { | |||||||||||||||||||||||
| channels.set(name, channel); | ||||||||||||||||||||||||
| registerToolCallDispatch(bridge, router, channels); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Restore sessions from previous run | ||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||
| await restoreAndLogSessions(router); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| await channel.connect(); | ||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||
|
|
@@ -365,6 +378,9 @@ async function startAll(proxy?: string): Promise<void> { | |||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| registerToolCallDispatch(bridge, router, channels); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||
| // Restore sessions from previous run | ||||||||||||||||||||||||
| await restoreAndLogSessions(router); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Connect all channels | ||||||||||||||||||||||||
| let connectedCount = 0; | ||||||||||||||||||||||||
| for (const [name, channel] of channels) { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Nit] 行为契约从「返回 agent 报告的 session ID」改成了「直接回显调用者传入的 sessionId,信任 ACP agent 用同样的 ID 加载」。这是正确的——
LoadSessionResponse规范里就没这个字段——但建议加一行注释,避免未来 reader 觉得「这看起来像 bug」又把它改回去: