Skip to content

Commit 1ee148e

Browse files
committed
merge: integrate remote main with local development
2 parents 4ef3867 + 3e238ee commit 1ee148e

45 files changed

Lines changed: 1222 additions & 125 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎adapters/common/__tests__/chat-runtime.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,69 @@ describe('ImChatRuntime authorization', () => {
266266
})
267267

268268
describe('ImChatRuntime session lifecycle', () => {
269+
it.each(['/status', '/stop', '/clear'])('reports a retryable disconnect for %s without changing the binding', async (command) => {
270+
const { runtime, bridge, httpClient, sessionStore, notices } = createRuntime()
271+
sessionStore.set(CHAT_ID, 'old-session', path.join(tmpDir, 'original-project'))
272+
httpClient.existingSessions.add('old-session')
273+
const original = sessionStore.get(CHAT_ID)
274+
bridge.waitForOpen = async () => false
275+
276+
await inbound(runtime, command)
277+
278+
expect(sessionStore.get(CHAT_ID)).toEqual(original)
279+
expect(httpClient.createdSessions).toEqual([])
280+
expect(bridge.sent).toEqual([])
281+
expect(bridge.stopped).toEqual([])
282+
expect(notices).toHaveLength(1)
283+
expect(notices[0]).toContain('重试')
284+
expect(notices[0]).not.toContain('当前没有活动会话')
285+
})
286+
287+
it.each(['timeout', 'error'])('preserves the stored session and project after a reconnect %s, then retries it', async (failure) => {
288+
const { runtime, bridge, httpClient, sessionStore, notices } = createRuntime()
289+
const workDir = path.join(tmpDir, 'original-project')
290+
sessionStore.set(CHAT_ID, 'old-session', workDir)
291+
httpClient.existingSessions.add('old-session')
292+
const original = sessionStore.get(CHAT_ID)
293+
let attempts = 0
294+
bridge.isSessionOpen = () => false
295+
bridge.waitForOpen = async () => {
296+
attempts += 1
297+
if (attempts === 1) {
298+
if (failure === 'error') throw new Error('connection failed')
299+
return false
300+
}
301+
return true
302+
}
303+
304+
await inbound(runtime, 'continue')
305+
306+
expect(sessionStore.get(CHAT_ID)).toEqual(original)
307+
expect(new SessionStore(path.join(tmpDir, 'adapter-sessions.json')).get(CHAT_ID)).toEqual(original)
308+
expect(httpClient.createdSessions).toEqual([])
309+
expect(bridge.sent).toEqual([])
310+
expect(notices.join('\n')).toContain('重试')
311+
312+
await inbound(runtime, 'continue')
313+
314+
expect(attempts).toBe(2)
315+
expect(bridge.getSessionId(CHAT_ID)).toBe('old-session')
316+
expect(sessionStore.get(CHAT_ID)).toEqual(original)
317+
expect(httpClient.createdSessions).toEqual([])
318+
expect(bridge.sent.map((item) => item.content)).toEqual(['continue'])
319+
})
320+
321+
it('creates a replacement only when the stored session is confirmed missing', async () => {
322+
const { runtime, bridge, httpClient, sessionStore } = createRuntime()
323+
sessionStore.set(CHAT_ID, 'deleted-session', path.join(tmpDir, 'original-project'))
324+
325+
await inbound(runtime, 'continue')
326+
327+
expect(httpClient.createdSessions).toEqual([tmpDir])
328+
expect(sessionStore.get(CHAT_ID)?.sessionId).not.toBe('deleted-session')
329+
expect(bridge.sent.map((item) => item.content)).toEqual(['continue'])
330+
})
331+
269332
it('creates a session on the first message and reuses it for the second', async () => {
270333
const { runtime, httpClient, bridge } = createRuntime()
271334

‎adapters/common/__tests__/session-recovery.test.ts‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,59 @@ function makeBridge(options?: { currentSessionId?: string | null; open?: boolean
4949
}
5050

5151
describe('restoreStoredSessionBinding', () => {
52+
it.each(['timeout', 'connect error', 'wait error'])('preserves the binding and transient state after a %s', async (failure) => {
53+
const entry = { sessionId: 'stored-session', workDir: '/original-project', updatedAt: 1 }
54+
const store = makeStore(entry)
55+
const bridge = makeBridge()
56+
const connect = bridge.connectSession
57+
const wait = bridge.waitForOpen
58+
if (failure === 'connect error') bridge.connectSession = () => { throw new Error('connect failed') }
59+
else bridge.waitForOpen = async () => {
60+
if (failure === 'wait error') throw new Error('wait failed')
61+
return false
62+
}
63+
let cleared = 0
64+
const options = {
65+
chatId: 'chat-1',
66+
bridge,
67+
sessionStore: store,
68+
httpClient: { sessionExists: async () => true },
69+
onServerMessage: () => {},
70+
logPrefix: '[Test]',
71+
clearTransientState: () => { cleared += 1 },
72+
}
73+
74+
expect(await restoreStoredSessionBinding(options)).toEqual({ status: 'unavailable', session: entry })
75+
expect(store.current()).toEqual(entry)
76+
expect(cleared).toBe(0)
77+
expect(bridge.calls).not.toContain('reset')
78+
79+
bridge.connectSession = connect
80+
bridge.waitForOpen = wait
81+
expect(await restoreStoredSessionBinding(options)).toEqual({ status: 'restored', session: entry })
82+
expect(store.current()).toEqual(entry)
83+
})
84+
85+
it.each([false, true])('keeps the binding when verification fails and reconnect returns %s', async (opened) => {
86+
const entry = { sessionId: 'stored-session', workDir: '/original-project', updatedAt: 1 }
87+
const store = makeStore(entry)
88+
const bridge = makeBridge()
89+
bridge.waitForOpen = async () => opened
90+
91+
const result = await restoreStoredSessionBinding({
92+
chatId: 'chat-1',
93+
bridge,
94+
sessionStore: store,
95+
httpClient: { sessionExists: async () => { throw new Error('HTTP timeout') } },
96+
onServerMessage: () => {},
97+
logPrefix: '[Test]',
98+
})
99+
100+
expect(result).toEqual({ status: opened ? 'restored' : 'unavailable', session: entry })
101+
expect(store.current()).toEqual(entry)
102+
expect(bridge.calls).toEqual(['connect:stored-session', 'handler'])
103+
})
104+
52105
it('resets stale bridge memory when server-side delete removed the stored mapping', async () => {
53106
const store = makeStore(null)
54107
const bridge = makeBridge({ currentSessionId: 'deleted-session', hasSession: true })
@@ -66,7 +119,7 @@ describe('restoreStoredSessionBinding', () => {
66119
},
67120
})
68121

69-
expect(restored).toBeNull()
122+
expect(restored).toEqual({ status: 'missing' })
70123
expect(bridge.calls).toEqual(['reset'])
71124
expect(cleared).toBe(1)
72125
})
@@ -89,7 +142,7 @@ describe('restoreStoredSessionBinding', () => {
89142
},
90143
})
91144

92-
expect(restored).toBeNull()
145+
expect(restored).toEqual({ status: 'missing' })
93146
expect(store.current()).toBeNull()
94147
expect(bridge.calls).toEqual([])
95148
expect(cleared).toBe(1)
@@ -113,7 +166,7 @@ describe('restoreStoredSessionBinding', () => {
113166
},
114167
})
115168

116-
expect(restored).toEqual(entry)
169+
expect(restored).toEqual({ status: 'restored', session: entry })
117170
expect(bridge.calls).toEqual(['reset', 'connect:stored-session', 'handler', 'wait'])
118171
expect(cleared).toBe(1)
119172
})
@@ -138,7 +191,7 @@ describe('restoreStoredSessionBinding', () => {
138191
logPrefix: '[Test]',
139192
})
140193

141-
expect(restored).toEqual(entry)
194+
expect(restored).toEqual({ status: 'restored', session: entry })
142195
expect(checked).toBe(0)
143196
expect(bridge.calls).toEqual([])
144197
})
@@ -157,7 +210,7 @@ describe('restoreStoredSessionBinding', () => {
157210
logPrefix: '[Test]',
158211
})
159212

160-
expect(restored).toEqual(entry)
213+
expect(restored).toEqual({ status: 'restored', session: entry })
161214
expect(bridge.calls).toEqual(['connect:stored-session', 'handler', 'wait'])
162215
})
163216
})

‎adapters/common/chat-runtime.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
parsePermissionCommand,
3535
type PermissionDecision,
3636
} from './permission.js'
37-
import { restoreStoredSessionBinding } from './session-recovery.js'
37+
import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from './session-recovery.js'
3838
import { SessionSelectionController } from './session-selection.js'
3939
import { syncImPermissionState } from './permission-sync.js'
4040
import type { SessionStore } from './session-store.js'
@@ -300,19 +300,19 @@ export class ImChatRuntime {
300300
return true
301301
}
302302
if (STOP_ALIASES.has(text)) {
303-
const stored = await this.ensureExistingSession(chatId)
304-
if (!stored) {
305-
await this.port.sendNotice(chatId, formatImStatus(null))
303+
const result = await this.ensureExistingSession(chatId)
304+
if (result.status !== 'restored') {
305+
await this.port.sendNotice(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
306306
return true
307307
}
308308
this.bridge.sendStopGeneration(chatId)
309309
await this.port.sendNotice(chatId, '已发送停止信号。')
310310
return true
311311
}
312312
if (CLEAR_ALIASES.has(text)) {
313-
const stored = await this.ensureExistingSession(chatId)
314-
if (!stored) {
315-
await this.port.sendNotice(chatId, formatImStatus(null))
313+
const result = await this.ensureExistingSession(chatId)
314+
if (result.status !== 'restored') {
315+
await this.port.sendNotice(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
316316
return true
317317
}
318318
this.clearTransientChatState(chatId)
@@ -502,7 +502,7 @@ export class ImChatRuntime {
502502

503503
// ---------- sessions ----------
504504

505-
async ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
505+
async ensureExistingSession(chatId: string): Promise<SessionRestoreResult> {
506506
return await restoreStoredSessionBinding({
507507
chatId,
508508
bridge: this.bridge,
@@ -515,8 +515,12 @@ export class ImChatRuntime {
515515
}
516516

517517
private async ensureSession(chatId: string): Promise<boolean> {
518-
const stored = await this.ensureExistingSession(chatId)
519-
if (stored) return true
518+
const result = await this.ensureExistingSession(chatId)
519+
if (result.status === 'restored') return true
520+
if (result.status === 'unavailable') {
521+
await this.port.sendNotice(chatId, SESSION_RECONNECT_NOTICE)
522+
return false
523+
}
520524

521525
if (this.defaultWorkDir) {
522526
return await this.createSessionForChat(chatId, this.defaultWorkDir)
@@ -621,8 +625,10 @@ export class ImChatRuntime {
621625
}
622626

623627
async buildStatusText(chatId: string): Promise<string> {
624-
const stored = await this.ensureExistingSession(chatId)
625-
if (!stored) return formatImStatus(null)
628+
const result = await this.ensureExistingSession(chatId)
629+
if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE
630+
if (result.status === 'missing') return formatImStatus(null)
631+
const stored = result.session
626632

627633
const runtime = this.getRuntimeState(chatId)
628634
let projectName = path.basename(stored.workDir) || stored.workDir

‎adapters/common/session-recovery.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import type { AdapterHttpClient } from './http-client.js'
22
import type { SessionEntry, SessionStore } from './session-store.js'
33
import type { ServerMessage, WsBridge } from './ws-bridge.js'
44

5+
export type SessionRestoreResult =
6+
| { status: 'restored'; session: SessionEntry }
7+
| { status: 'missing' }
8+
| { status: 'unavailable'; session: SessionEntry }
9+
10+
export const SESSION_RECONNECT_NOTICE = '暂时无法连接原会话,已保留会话和工作目录,请稍后重试。'
11+
512
type BridgeSessionOps = Pick<
613
WsBridge,
714
| 'connectSession'
@@ -41,11 +48,11 @@ export async function restoreStoredSessionBinding({
4148
onServerMessage,
4249
logPrefix,
4350
clearTransientState,
44-
}: RestoreStoredSessionBindingOptions): Promise<SessionEntry | null> {
51+
}: RestoreStoredSessionBindingOptions): Promise<SessionRestoreResult> {
4552
const stored = sessionStore.get(chatId)
4653
if (!stored) {
4754
resetStaleBridge(chatId, bridge, clearTransientState)
48-
return null
55+
return { status: 'missing' }
4956
}
5057

5158
const currentSessionId = bridge.getSessionId(chatId)
@@ -54,7 +61,7 @@ export async function restoreStoredSessionBinding({
5461
}
5562

5663
if (bridge.isSessionOpen(chatId, stored.sessionId)) {
57-
return stored
64+
return { status: 'restored', session: stored }
5865
}
5966

6067
let exists = true
@@ -73,11 +80,23 @@ export async function restoreStoredSessionBinding({
7380
const hadBridgeSession = bridge.hasSession(chatId)
7481
resetStaleBridge(chatId, bridge, clearTransientState)
7582
if (!hadBridgeSession) clearTransientState?.()
76-
return null
83+
return { status: 'missing' }
7784
}
7885

79-
bridge.connectSession(chatId, stored.sessionId)
80-
bridge.onServerMessage(chatId, onServerMessage)
81-
const opened = await bridge.waitForOpen(chatId)
82-
return opened ? stored : null
86+
// A transport failure is not evidence that the session was deleted. Keep
87+
// its binding so the next inbound message can retry the same session.
88+
try {
89+
bridge.connectSession(chatId, stored.sessionId)
90+
bridge.onServerMessage(chatId, onServerMessage)
91+
if (await bridge.waitForOpen(chatId)) {
92+
return { status: 'restored', session: stored }
93+
}
94+
} catch (err) {
95+
console.warn(
96+
`${logPrefix} Failed to reconnect stored session ${stored.sessionId}: ${
97+
err instanceof Error ? err.message : String(err)
98+
}`,
99+
)
100+
}
101+
return { status: 'unavailable', session: stored }
83102
}

‎adapters/dingtalk/index.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
formatProjectSelectionOutcome,
2929
ProjectSelectionController,
3030
} from '../common/project-selection-router.js'
31-
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
31+
import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js'
3232
import { isAllowedUser, tryPair } from '../common/pairing.js'
3333
import { AttachmentStore } from '../common/attachment/attachment-store.js'
3434
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
@@ -248,7 +248,7 @@ function clearPendingPermissions(chatId: string): void {
248248
pendingPermissions.delete(chatId)
249249
}
250250

251-
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
251+
async function ensureExistingSession(chatId: string): Promise<SessionRestoreResult> {
252252
return await restoreStoredSessionBinding({
253253
chatId,
254254
bridge,
@@ -261,8 +261,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
261261
}
262262

263263
async function buildStatusText(chatId: string): Promise<string> {
264-
const stored = await ensureExistingSession(chatId)
265-
if (!stored) return formatImStatus(null)
264+
const result = await ensureExistingSession(chatId)
265+
if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE
266+
if (result.status === 'missing') return formatImStatus(null)
267+
const stored = result.session
266268

267269
const runtime = getRuntimeState(chatId)
268270
let projectName = path.basename(stored.workDir) || stored.workDir
@@ -312,8 +314,12 @@ async function buildStatusText(chatId: string): Promise<string> {
312314
}
313315

314316
async function ensureSession(chatId: string): Promise<boolean> {
315-
const stored = await ensureExistingSession(chatId)
316-
if (stored) return true
317+
const result = await ensureExistingSession(chatId)
318+
if (result.status === 'restored') return true
319+
if (result.status === 'unavailable') {
320+
await sendText(chatId, SESSION_RECONNECT_NOTICE)
321+
return false
322+
}
317323

318324
return await createSessionForChat(chatId, defaultWorkDir)
319325
}
@@ -510,9 +516,9 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac
510516
return
511517
}
512518
if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) {
513-
const stored = await ensureExistingSession(chatId)
514-
if (!stored) {
515-
await sendText(chatId, formatImStatus(null))
519+
const result = await ensureExistingSession(chatId)
520+
if (result.status !== 'restored') {
521+
await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
516522
return
517523
}
518524
clearTransientChatState(chatId)
@@ -525,9 +531,9 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac
525531
return
526532
}
527533
if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) {
528-
const stored = await ensureExistingSession(chatId)
529-
if (!stored) {
530-
await sendText(chatId, formatImStatus(null))
534+
const result = await ensureExistingSession(chatId)
535+
if (result.status !== 'restored') {
536+
await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null))
531537
return
532538
}
533539
bridge.sendStopGeneration(chatId)

0 commit comments

Comments
 (0)