dsh-session-bridge 0.3.2-alpha.1 → 0.3.2-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression tests for the session-bridge core wait/stall logic (GitHub issue #1):
4
+ *
5
+ * Bug 1 — session_bridge_wait 返回 "(no text)" 并跑满超时,即使回复早已存在。
6
+ * Bug 2 — session_bridge_status 把空闲会话标成 [STALLED]。
7
+ *
8
+ * Runs against src/core.ts directly (Node type stripping — no build, no deps, no
9
+ * host/Dsh runtime needed): `npm test`. The fake Session only has to expose the
10
+ * read path core.sessionEvents() probes (`snapshotEvents()`).
11
+ */
12
+ import assert from 'node:assert/strict'
13
+ import { foldMessages, isStalled, maxSeq, waitForReply } from '../src/core.ts'
14
+
15
+ let passed = 0
16
+ let failed = 0
17
+
18
+ async function test(name, fn) {
19
+ try {
20
+ await fn()
21
+ passed += 1
22
+ console.log('PASS ' + name)
23
+ } catch (error) {
24
+ failed += 1
25
+ console.error('FAIL ' + name)
26
+ console.error(' ' + (error instanceof Error ? error.message : String(error)))
27
+ }
28
+ }
29
+
30
+ // --- synthetic event log -----------------------------------------------------
31
+ const turnStart = (seq, turn = 1) => ({ seq, time: seq, type: 'turn/start', data: { turn } })
32
+ const turnEnd = (seq, turn = 1) => ({ seq, time: seq, type: 'turn/end', data: { turn } })
33
+ const userMsg = (seq, text) => ({ seq, time: seq, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } })
34
+ const assistantText = (seq, text, step = 1) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step, message: { content: [{ type: 'text', text }] } } })
35
+ /** 纯工具调用中间行:无文本(foldMessages 会给出一个没有 text 的 assistant 行)。 */
36
+ const assistantTool = (seq, name = 'bash', step = 2) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step, message: { content: [{ type: 'text', text: '' }], toolCalls: [{ name }] } } })
37
+ /** 带 reasoning 的已完成段落。 */
38
+ const assistantReasoning = (seq, reasoning, text) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step: 1, message: { content: [{ type: 'reasoning', text: reasoning }, { type: 'text', text }] } } })
39
+ /** 假 Session:core.sessionEvents() 会走 snapshotEvents() 分支。 */
40
+ const fakeSession = (events) => ({ snapshotEvents: () => events })
41
+
42
+ // --- Bug 2: stall semantics ---------------------------------------------------
43
+ await test('isStalled: idle sessions are never stalled (issue #1 Bug 2)', () => {
44
+ assert.equal(isStalled('idle', 10_000_000, 60_000), false)
45
+ assert.equal(isStalled('idle', 0, 60_000), false)
46
+ })
47
+
48
+ await test('isStalled: running sessions stall only past the threshold', () => {
49
+ assert.equal(isStalled('running', 10_000_000, 60_000), true)
50
+ assert.equal(isStalled('running', 60_000, 60_000), false) // "exceeds" is strict
51
+ assert.equal(isStalled('running', 60_001, 60_000), true)
52
+ assert.equal(isStalled('running', null, 60_000), false)
53
+ })
54
+
55
+ // --- Bug 1: wait never loses an already-landed reply --------------------------
56
+ await test('wait reply-mode: pre-existing reply at baseline -> stale + text (main symptom)', async () => {
57
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), turnEnd(3)]
58
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1 })
59
+ assert.equal(result.message?.text, 'pong')
60
+ assert.equal(result.message?.seq, 2)
61
+ assert.equal(result.stale, true)
62
+ assert.equal(result.timedOut, true)
63
+ assert.equal(result.seq, 2)
64
+ })
65
+
66
+ await test('wait reply-mode: never returns a later text-less step as the reply', async () => {
67
+ // 回复已落地,目标会话随后又跑了工具调用/推理步(更晚但无文本)。
68
+ // 旧实现 message = textReply ?? latest 会把这个无文本行交出去 -> 渲染 "(no text)"。
69
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), assistantTool(3), turnEnd(4)]
70
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1 })
71
+ assert.equal(result.message?.text, 'pong')
72
+ assert.equal(result.message?.seq, 2)
73
+ assert.equal(result.stale, true)
74
+ assert.equal(result.timedOut, true)
75
+ })
76
+
77
+ await test('wait reply-mode: legacy baseline (last text row) with a newer tool row', async () => {
78
+ // 旧默认 baseline = 最后一条带文本行;更晚的无文本行让它返回无文本行。
79
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), assistantTool(3)]
80
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1 })
81
+ assert.equal(result.message?.text, 'pong')
82
+ assert.equal(result.stale, true)
83
+ assert.equal(result.timedOut, true)
84
+ })
85
+
86
+ await test('wait reply-mode: a genuinely new reply returns immediately, stale=false', async () => {
87
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'old')]
88
+ const baseline = maxSeq(events)
89
+ setTimeout(() => { events.push(assistantText(3, 'new')) }, 30)
90
+ const started = Date.now()
91
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: baseline, timeoutMs: 2_000 })
92
+ assert.equal(result.message?.text, 'new')
93
+ assert.equal(result.stale, false)
94
+ assert.equal(result.timedOut, false)
95
+ assert.ok(Date.now() - started < 1_000, 'should return as soon as the new reply lands')
96
+ })
97
+
98
+ await test('wait segment-mode: pre-existing segment -> stale + segment fields', async () => {
99
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantReasoning(2, 'thinking…', 'answer'), turnEnd(3)]
100
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1, waitForSegment: true })
101
+ assert.equal(result.stale, true)
102
+ assert.equal(result.seq, 2)
103
+ assert.equal(result.message?.text, 'answer')
104
+ assert.equal(result.message?.reasoning, 'thinking…')
105
+ })
106
+
107
+ await test('wait reply-mode: no assistant text at all -> message null, stale=false', async () => {
108
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantTool(2)]
109
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1 })
110
+ assert.equal(result.message, null)
111
+ assert.equal(result.stale, false)
112
+ assert.equal(result.timedOut, true)
113
+ assert.equal(result.seq, maxSeq(events))
114
+ })
115
+
116
+ await test('wait requireTurnEnd: new row without turn/end -> timedOut, stale fallback text', async () => {
117
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'first'), assistantTool(3)]
118
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1, requireTurnEnd: true })
119
+ assert.equal(result.timedOut, true)
120
+ assert.equal(result.turnEnded, false)
121
+ assert.equal(result.stale, true)
122
+ assert.equal(result.message?.text, 'first')
123
+ })
124
+
125
+ await test('wait requireTurnEnd: nothing new at all -> timedOut (was misreported as false)', async () => {
126
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'first'), assistantTool(3), turnEnd(4)]
127
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1, requireTurnEnd: true })
128
+ assert.equal(result.timedOut, true)
129
+ assert.equal(result.stale, true)
130
+ assert.equal(result.message?.text, 'first')
131
+ })
132
+
133
+ await test('wait sinceSeq=-1 anchor (create path): existing reply counts as new', async () => {
134
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'hello')]
135
+ const started = Date.now()
136
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: -1, timeoutMs: 2_000 })
137
+ assert.equal(result.message?.text, 'hello')
138
+ assert.equal(result.stale, false)
139
+ assert.equal(result.timedOut, false)
140
+ assert.ok(Date.now() - started < 1_000, 'should return without waiting (nothing to wait for)')
141
+ })
142
+
143
+ // --- sanity -------------------------------------------------------------------
144
+ await test('maxSeq: empty log is -1 (anchor sentinel)', () => {
145
+ assert.equal(maxSeq([]), -1)
146
+ assert.equal(maxSeq(undefined), -1)
147
+ assert.equal(maxSeq([turnStart(0), assistantText(5, 'x')]), 5)
148
+ assert.equal(foldMessages([assistantTool(1)]).length, 1)
149
+ assert.equal(foldMessages([assistantTool(1)])[0].text, undefined)
150
+ })
151
+
152
+ console.log('')
153
+ console.log(String(passed) + ' passed, ' + String(failed) + ' failed')
154
+ if (failed > 0) process.exit(1)
package/src/core.ts CHANGED
@@ -44,6 +44,12 @@ export interface BridgeWaitResult {
44
44
  seq: number
45
45
  turnEnded: boolean
46
46
  timedOut: boolean
47
+ /**
48
+ * true = 返回的是等待开始前就已经存在的输出(整个等待窗口内没有出现新的文本
49
+ * 回复 / 新段落)。此时 message 仍带正文,调用方据此区分"等到了新回复"与
50
+ * "拿到的是既有回复"。不变式:stale === (message !== null && message.seq <= baselineSeq)。
51
+ */
52
+ stale: boolean
47
53
  aborted: boolean
48
54
  waitedMs: number
49
55
  }
@@ -64,11 +70,19 @@ export interface BridgeFindItem {
64
70
 
65
71
  /**
66
72
  * 兼容读取不同 dsh-session 版本上的会话事件:
67
- * - 旧版 `Session` 暴露 `get events(): readonly SessionEvent[]`;
68
- * - 新版(如宿主实际运行的 0.1.2-rc.x)将 `get events` 改为
69
- * `snapshotEvents(fromSeq?, toSeqExclusive?)` 方法——`.events` 直接读取会
70
- * 得到 `undefined`,对 for...of 迭代即抛 `events is not iterable`。
73
+ * - 旧版 \`Session\` 暴露 \`get events(): readonly SessionEvent[]\`;
74
+ * - 新版将 \`get events\` 改为 \`snapshotEvents(fromSeq?, toSeqExclusive?)\` 方法——
75
+ * \`.events\` 直接读取会得到 \`undefined\`,对 for...of 迭代即抛
76
+ * \`events is not iterable\`。
71
77
  * 两者都读不到(或 session 不存在)时回退为空数组,绝不抛迭代错误。
78
+ *
79
+ * 注意:dsh 0.1.6-alpha.1 起 \`snapshotEvents()\`(连同 \`eventAt()\` /
80
+ * \`ownEvents()\`)已标记 @deprecated —— 官方策略是"现有逻辑可暂不迁移,但禁止
81
+ * 新增调用";其替代不是同步读,而是(a)恢复后读取 Session 投影/派生状态,或
82
+ * (b)按需异步分页读取历史窗口(见 dsh 决策
83
+ * 2026-09-09-deprecate-synchronous-session-event-reads)。本函数的调用方
84
+ * (foldMessages / segmentsSince / wait 基线)仍依赖完整同步快照,故按该决定暂缓
85
+ * 迁移;将来官方补齐分页读后,只需改造这一个入口。
72
86
  */
73
87
  export function sessionEvents(session: unknown): readonly SessionEvent[] {
74
88
  const s = session as {
@@ -421,8 +435,11 @@ export interface WaitForReplyOptions {
421
435
  * 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
422
436
  * 此时才会等到其后出现 turn/end。
423
437
  *
424
- * 返回时 message 为"最新的带文本 assistant 行",否则为最新 assistant 行(可能无文本,
425
- * 如纯工具调用中间态);超时/中止返回已收集内容。
438
+ * 返回的 message:默认是 baseline 之后最新的一条**带文本** assistant 行;整个等待
439
+ * 窗口内没有等到新文本回复时,回落为 baseline 及之前最新的一条带文本回复并置
440
+ * stale:true(绝不把更晚的无文本中间行当回复返回 —— 那正是渲染层打印 "(no text)"
441
+ * 的来源)。segment 模式同理:新段落优先,否则回落既有段落。
442
+ * 超时/中止返回已收集内容;timedOut 表示"本次等待要求的输出未在预算内出现"。
426
443
  */
427
444
  export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWaitResult> {
428
445
  const started = Date.now()
@@ -458,23 +475,60 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
458
475
  if (Date.now() >= deadline) break
459
476
  await sleep(100)
460
477
  }
478
+ // 等待窗口内是否观察到新输出(即 done 条件是否达成):用于 timedOut / stale 判定。
479
+ const observedNew = waitForSegment ? segment !== null : textReply !== null
480
+ const sawNewRow = latest !== null
481
+ // 零新输出时回落既有内容;回落扫描限定 seq <= baselineSeq,保证 stale 不变式成立,
482
+ // 也不会把"其实算新"的内容标成 stale。
483
+ let stale = false
484
+ if (waitForSegment) {
485
+ if (segment === null) {
486
+ const prev = lastSegmentUpTo(sessionEvents(opts.session), opts.baselineSeq)
487
+ if (prev !== null) { segment = prev; stale = true }
488
+ }
489
+ } else if (textReply === null) {
490
+ const prev = lastTextRowUpTo(foldMessages(sessionEvents(opts.session)), opts.baselineSeq)
491
+ if (prev !== null) { textReply = prev; stale = true }
492
+ }
461
493
  // 段落模式返回该段(把 reasoning 并进返回行,便于“按段落读思维链”);否则返回最新文本行。
462
- let message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
494
+ const message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
463
495
  seq: segment.seq, time: segment.time, role: 'assistant', images: 0,
464
496
  ...(segment.text !== undefined ? { text: segment.text } : {}),
465
497
  ...(segment.reasoning !== undefined ? { reasoning: segment.reasoning } : {}),
466
498
  ...(segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}),
467
- } : (textReply ?? latest)
499
+ } : textReply
468
500
  return {
469
501
  message,
470
502
  seq: message === null ? opts.baselineSeq : message.seq,
471
503
  turnEnded,
472
- timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : waitForSegment ? segment === null : textReply === null,
504
+ // done 条件未达成即超时。requireTurnEnd 下"连新行都没出现"同样算超时
505
+ // (旧写法 latest !== null && !turnEnded 会把这种情况误报为未超时)。
506
+ timedOut: requireTurnEnd ? !(sawNewRow && turnEnded) : !observedNew,
507
+ stale,
473
508
  aborted: opts.signal !== undefined && opts.signal.aborted,
474
509
  waitedMs: Date.now() - started,
475
510
  }
476
511
  }
477
512
 
513
+ /** seq 及之前最新一个已完成输出段落(wait 零新输出时回落既有段落)。 */
514
+ function lastSegmentUpTo(events: readonly SessionEvent[], seq: number): BridgeSegment | null {
515
+ const segs = segmentsSince(events)
516
+ for (let i = segs.length - 1; i >= 0; i -= 1) {
517
+ const seg = segs[i]
518
+ if (seg !== undefined && seg.seq <= seq) return seg
519
+ }
520
+ return null
521
+ }
522
+
523
+ /** seq 及之前最新一条带文本的 assistant 行(wait 零新文本回复时回落既有回复)。 */
524
+ function lastTextRowUpTo(rows: readonly BridgeMessageRow[], seq: number): BridgeMessageRow | null {
525
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
526
+ const row = rows[i]
527
+ if (row !== undefined && row.role === 'assistant' && row.text !== undefined && row.seq <= seq) return row
528
+ }
529
+ return null
530
+ }
531
+
478
532
  export interface TargetCwdArgs {
479
533
  workspaceId?: string
480
534
  cwd?: string
@@ -542,6 +596,15 @@ export async function attachSessionToWorkspace(ctx: Context, sessionId: SessionI
542
596
  return workspace.id
543
597
  }
544
598
 
599
+ /**
600
+ * 卡住判定(唯一真源,status 渲染与监控 watchdog 共用):只有 **running** 会话才可能
601
+ * "卡住" —— 空闲/已收尾的会话没有进展是正常状态,不能报 stall。阈值语义为"超过"
602
+ * (严格大于),与工具描述/README 的 "more than stalledMs" 一致。
603
+ */
604
+ export function isStalled(running: 'running' | 'idle', stalledMs: number | null, thresholdMs: number): boolean {
605
+ return running === 'running' && stalledMs !== null && stalledMs > thresholdMs
606
+ }
607
+
545
608
  /** 一次会话监控快照:供"监控线程"判断主任务是否在跑、有无进展、是否卡住。 */
546
609
  export interface BridgeStatusSnapshot {
547
610
  sessionId: string
package/src/monitor.ts CHANGED
@@ -14,6 +14,7 @@ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
14
14
  import {
15
15
  cancelLiveSession,
16
16
  getLiveAgent,
17
+ isStalled,
17
18
  sendLiveMessage,
18
19
  statusSnapshot,
19
20
  type BridgeStatusSnapshot,
@@ -224,9 +225,8 @@ export class SessionMonitor {
224
225
  }
225
226
 
226
227
  // 2) 卡住判定:仅对 running 会话有意义(距最近事件超过阈值)。
227
- const stalled = snapshot.running === 'running'
228
- && snapshot.stalledMs !== null
229
- && snapshot.stalledMs > (entry.config.stalledMs ?? 60000)
228
+ // 判定走 core.isStalled(唯一真源,与 session_bridge_status 的 [STALLED] 标注一致)。
229
+ const stalled = isStalled(snapshot.running, snapshot.stalledMs, entry.config.stalledMs ?? 60000)
230
230
  if (stalled) {
231
231
  entry.stuckCount += 1
232
232
  entry.lastActionAt = Date.now()
package/src/tools.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  attachSessionToWorkspace,
21
21
  foldMessages,
22
22
  inspectPersistedSession,
23
+ isStalled,
23
24
  maxSeq,
24
25
  resolveTargetCwd,
25
26
  segmentsSince,
@@ -76,6 +77,15 @@ function liveAgents(env: BridgeEnv): LiveAgentLike[] {
76
77
  return agents.list()
77
78
  }
78
79
 
80
+ /** 等待结果里的状态附注行(wait / send / create 三处渲染共用)。 */
81
+ function waitNotes(reply: Record<string, unknown>): string[] {
82
+ const notes: string[] = []
83
+ if (reply.timedOut === true) notes.push('[wait timed out]')
84
+ if (reply.stale === true) notes.push('[stale] pre-existing reply — nothing new arrived during the wait')
85
+ if (reply.aborted === true) notes.push('[wait aborted]')
86
+ return notes
87
+ }
88
+
79
89
  /** 渲染等待结果为 JSON 友好值。 */
80
90
  function renderWait(wait: BridgeWaitResult): Record<string, unknown> {
81
91
  const row = wait.message
@@ -91,6 +101,7 @@ function renderWait(wait: BridgeWaitResult): Record<string, unknown> {
91
101
  seq: wait.seq,
92
102
  turnEnded: wait.turnEnded,
93
103
  timedOut: wait.timedOut,
104
+ ...(wait.stale === true ? { stale: true } : {}),
94
105
  aborted: wait.aborted,
95
106
  waitedMs: wait.waitedMs,
96
107
  }
@@ -154,9 +165,10 @@ function registerCreate(env: BridgeEnv): void {
154
165
  if (typeof v.cwd === 'string') lines.push(`cwd: ${v.cwd}`)
155
166
  if (typeof v.title === 'string') lines.push(`title: ${v.title}`)
156
167
  const reply = v.reply as Record<string, unknown> | undefined
168
+ if (typeof v.sinceSeq === 'number') lines.push(`sinceSeq: ${String(v.sinceSeq)} (pass to session_bridge_wait)`)
157
169
  if (reply !== undefined) {
158
170
  lines.push(`reply: ${String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)')}`)
159
- if (reply.timedOut === true) lines.push('[wait timed out]')
171
+ lines.push(...waitNotes(reply))
160
172
  }
161
173
  return [{ type: 'text' as const, text: lines.join('\n') }]
162
174
  },
@@ -245,8 +257,12 @@ function registerCreate(env: BridgeEnv): void {
245
257
  })
246
258
 
247
259
  let reply: ReturnType<typeof renderWait> | undefined
260
+ // 发送 prompt 之前的日志锚点:异步创建时交还调用方,供之后精确 wait 取回首条回复。
261
+ // 新建会话的 maxSeq 为 -1(尚无事件),所以"未发送"用 undefined 哨兵而不是数值比较。
262
+ let createBaseline: number | undefined
248
263
  if (typeof args.prompt === 'string' && args.prompt.trim() !== '') {
249
264
  const baseline = maxSeq(sessionEvents(agent.session))
265
+ createBaseline = baseline
250
266
  agent.followup(userMessage(args.prompt.trim()))
251
267
  env.registry.touch(sessionId)
252
268
  if (args.waitForReply === true) {
@@ -259,6 +275,7 @@ function registerCreate(env: BridgeEnv): void {
259
275
  cwd: targetCwd,
260
276
  ...(workspaceId === undefined ? {} : { workspaceId }),
261
277
  ...(typeof args.title === 'string' && args.title.trim() !== '' ? { title: args.title.trim() } : {}),
278
+ ...(reply === undefined && createBaseline !== undefined ? { sinceSeq: createBaseline } : {}),
262
279
  ...(reply === undefined ? {} : { reply }),
263
280
  })
264
281
  },
@@ -290,9 +307,10 @@ function registerSend(env: BridgeEnv): void {
290
307
  const v = value as Record<string, unknown>
291
308
  const reply = v.reply as Record<string, unknown> | null | undefined
292
309
  const lines = ['sent to ' + String(v.sessionId)]
310
+ if (typeof v.sinceSeq === 'number') lines.push('sinceSeq: ' + String(v.sinceSeq) + ' (pass to session_bridge_wait)')
293
311
  if (reply !== null && reply !== undefined) {
294
312
  lines.push('reply: ' + String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)'))
295
- if ((reply as Record<string, unknown>).timedOut === true) lines.push('[wait timed out]')
313
+ lines.push(...waitNotes(reply))
296
314
  }
297
315
  return [{ type: 'text' as const, text: lines.join('\n') }]
298
316
  },
@@ -316,7 +334,9 @@ function registerSend(env: BridgeEnv): void {
316
334
  ...(sendWorkspaceId === undefined ? {} : { workspaceId: sendWorkspaceId }),
317
335
  })
318
336
  const reply = await maybeWait(env, agent.session, baseline, args, exec.signal)
319
- return asJson({ accepted: true, sessionId: args.sessionId, ...(reply === undefined ? {} : { reply }) })
337
+ // 未同步等待时把发送前的锚点交还给调用方:之后无论隔多久,用该 sinceSeq 调
338
+ // session_bridge_wait 都能稳定取回"本次发送之后的回复"(不受调用方延迟影响)。
339
+ return asJson({ accepted: true, sessionId: args.sessionId, ...(reply === undefined ? { sinceSeq: baseline } : { reply }) })
320
340
  },
321
341
  }))
322
342
  }
@@ -413,10 +433,10 @@ interface WaitArgsTool {
413
433
  function registerWait(env: BridgeEnv): void {
414
434
  env.ctx.tools.register(defineTool({
415
435
  name: 'session_bridge_wait',
416
- description: 'Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).',
436
+ description: 'Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. If no new output arrives within the budget, the latest PRE-EXISTING reply/segment is returned with stale=true (so an already-landed reply is never lost as "(no text)"). Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own); to read a reply that may already exist, pass the sinceSeq anchor returned by send/create.',
417
437
  parameters: {
418
438
  sessionId: { type: 'string', required: true, description: 'Session id to wait on.' },
419
- sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time).' },
439
+ sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time; -1 = count every event, the anchor returned by create for a brand-new session).' },
420
440
  timeoutMs: { type: 'number', description: 'Wait budget in milliseconds (default 180000, max 3600000); timed out waits return the partial result instead of failing.' },
421
441
  requireTurnEnd: { type: 'boolean', description: 'When true, wait for the reply turn/end to settle before returning (default false; false returns as soon as the reply text is readable).' },
422
442
  waitFor: { type: 'string', enum: ['reply', 'segment'], description: 'reply (default) waits for a new assistant TEXT reply; segment waits for any new completed output segment (an assistant/message step, incl. reasoning/tool turns) and returns it immediately, without waiting for the whole turn.' },
@@ -428,10 +448,11 @@ function registerWait(env: BridgeEnv): void {
428
448
  const reply = v.reply as Record<string, unknown> | null | undefined
429
449
  if (reply === null || reply === undefined) return [{ type: 'text' as const, text: 'no reply observed' }]
430
450
  const message = (reply.message as Record<string, unknown> | null)
431
- const lines = ['reply seq ' + String(reply.seq) + ': ' + String(message === null ? '(no text)' : message.text ?? '(no text)')]
451
+ const lines = [message === null
452
+ ? 'no new reply within ' + String(reply.waitedMs ?? '?') + 'ms (no assistant text in this session)'
453
+ : 'reply seq ' + String(reply.seq) + ': ' + String(message.text ?? '(no text)')]
432
454
  if (typeof reply.turnEnded === 'boolean') lines.push('turnEnded: ' + String(reply.turnEnded))
433
- if (reply.timedOut === true) lines.push('[wait timed out]')
434
- if (reply.aborted === true) lines.push('[wait aborted]')
455
+ lines.push(...waitNotes(reply))
435
456
  return [{ type: 'text' as const, text: lines.join('\n') }]
436
457
  },
437
458
  },
@@ -441,34 +462,25 @@ function registerWait(env: BridgeEnv): void {
441
462
  if (agent === undefined) {
442
463
  throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (waiting requires a live session)')
443
464
  }
444
- // 默认 baseline = 当前最后一条(带文本的)assistant 行的 seq:让 wait 只等待
445
- // 之后新出现的输出,避免把"已存在的输出"当成待等内容,同时不被文本后追加的
446
- // 无文本中间块(推理尾块/工具结果)干扰。segment 模式下以最后一个已完成段落为界。
447
- const waitSegment = args.waitFor === 'segment'
448
- let baseline: number
449
- if (typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) {
450
- baseline = args.sinceSeq
451
- } else if (waitSegment) {
452
- const segs = segmentsSince(sessionEvents(agent.session))
453
- baseline = segs.length === 0 ? -1 : (segs[segs.length - 1]?.seq ?? -1)
454
- } else {
455
- let lastText = -1
456
- for (const row of foldMessages(sessionEvents(agent.session))) {
457
- if (row.text !== undefined) lastText = row.seq
458
- }
459
- baseline = lastText
460
- }
465
+ // 默认 baseline = 调用时刻的日志最大 seq(即工具描述承诺的 "latest seq at call
466
+ // time",两种 waitFor 模式统一):只等待之后新出现的输出。既有回复不会被误当成
467
+ // 新输出,但也绝不会被吞掉 —— waitForReply 在零新输出时会回落它并置 stale:true。
468
+ // 调用方若要在"回复早已落地"之后再精确取回它,应传 send/create 返回的 sinceSeq 锚点。
469
+ const baseline = typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= -1
470
+ ? args.sinceSeq
471
+ : maxSeq(sessionEvents(agent.session))
461
472
  const result = await waitForReply({
462
473
  session: agent.session,
463
474
  baselineSeq: baseline,
464
475
  timeoutMs: clampTimeout(args.timeoutMs),
465
476
  signal: exec.signal,
466
477
  requireTurnEnd: args.requireTurnEnd === true,
467
- ...(waitSegment ? { waitForSegment: true } : {}),
478
+ ...(args.waitFor === 'segment' ? { waitForSegment: true } : {}),
468
479
  })
469
480
  env.registry.touch(args.sessionId)
470
481
  return asJson({
471
482
  sessionId: args.sessionId,
483
+ sinceSeq: baseline,
472
484
  reply: renderWait(result),
473
485
  running: agent.status === 'running',
474
486
  })
@@ -961,7 +973,11 @@ function pruneReasoning(snapshot: BridgeStatusSnapshot, mode: StatusReasoning):
961
973
  return rest
962
974
  }
963
975
 
964
- /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。 */
976
+ /**
977
+ * 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。
978
+ * 卡住判定走 core.isStalled(唯一真源):只有 running 会话才可能被标 [STALLED] ——
979
+ * 空闲/已收尾的会话"很久没事件"是正常状态,不是卡住(与监控 watchdog 一致)。
980
+ */
965
981
  function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number): string[] {
966
982
  const lines: string[] = []
967
983
  const runLabel = snapshot.running === 'running' ? 'running' : 'idle'
@@ -969,7 +985,7 @@ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number
969
985
  if (snapshot.openTurn) lines.push(`openTurn: yes (turn #${snapshot.lastTurn})`)
970
986
  else lines.push(`openTurn: no (last turn #${snapshot.lastTurn})`)
971
987
  if (snapshot.stalledMs !== null) {
972
- const stalled = snapshot.stalledMs >= stalledMsThreshold
988
+ const stalled = isStalled(snapshot.running, snapshot.stalledMs, stalledMsThreshold)
973
989
  lines.push(`lastActivity: now-${snapshot.stalledMs}ms${stalled ? ' [STALLED]' : ''}`)
974
990
  }
975
991
  if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`)
@@ -984,10 +1000,10 @@ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number
984
1000
  function registerStatus(env: BridgeEnv): void {
985
1001
  env.ctx.tools.register(defineTool({
986
1002
  name: 'session_bridge_status',
987
- description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session\'s chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks the session as stalled when the time since the last event exceeds it. Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
1003
+ description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session\'s chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks a RUNNING session as stalled once the time since the last event exceeds it (idle sessions are never flagged — a quiet finished session is not stuck). Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
988
1004
  parameters: {
989
1005
  sessionId: { type: 'string', required: true, description: 'Session id to inspect (must be live).' },
990
- stalledMsThreshold: { type: 'number', description: 'Mark the session STALLED when time since the last event exceeds this many ms (default 60000).' },
1006
+ stalledMsThreshold: { type: 'number', description: 'Mark a RUNNING session STALLED when time since the last event exceeds this many ms (default 60000); idle sessions are never marked.' },
991
1007
  recent: { type: 'number', description: 'Number of recent messages to include in the snapshot (default 8, max 20).' },
992
1008
  reasoning: { type: 'string', enum: ['none', 'last', 'live', 'tail'], description: 'Which chain-of-thought fields to include: tail (default) returns lastReasoning/liveReasoning/reasoningTail; last only the finalized reasoning; live only the in-flight reasoning; none drops all reasoning fields.' },
993
1009
  },