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.
- package/README.md +50 -18
- package/README.zh.md +36 -14
- package/dsh.plugin.json +1 -1
- package/lib/index.js +118 -57
- package/package.json +2 -1
- package/scripts/check-dsh-compat.mjs +70 -1
- package/scripts/test-bridge-core.mjs +154 -0
- package/src/core.ts +72 -9
- package/src/monitor.ts +3 -3
- package/src/tools.ts +46 -30
|
@@ -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
|
-
* - 旧版
|
|
68
|
-
* -
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
} :
|
|
499
|
+
} : textReply
|
|
468
500
|
return {
|
|
469
501
|
message,
|
|
470
502
|
seq: message === null ? opts.baselineSeq : message.seq,
|
|
471
503
|
turnEnded,
|
|
472
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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 =
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
...(
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|