dsh-session-bridge 0.3.2-alpha.2 → 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 +32 -16
- package/README.zh.md +25 -14
- package/dsh.plugin.json +1 -1
- package/lib/index.js +85 -30
- package/package.json +2 -1
- package/scripts/check-dsh-compat.mjs +19 -2
- package/scripts/test-bridge-core.mjs +154 -0
- package/src/core.ts +60 -5
- package/src/monitor.ts +3 -3
- package/src/tools.ts +46 -30
package/README.md
CHANGED
|
@@ -16,18 +16,26 @@ action does.
|
|
|
16
16
|
session (top-level UI session) in the current workspace, or in another
|
|
17
17
|
workspace when you pass `workspaceId` / `cwd`. It can send one first prompt
|
|
18
18
|
and optionally block until the first reply. Provider / model / reasoning
|
|
19
|
-
effort are inherited from the calling session by default.
|
|
19
|
+
effort are inherited from the calling session by default. An async create
|
|
20
|
+
(no `waitForReply`) returns a `sinceSeq` anchor for a later precise `wait`.
|
|
20
21
|
- **Send messages to any session.** `session_bridge_send` appends a turn
|
|
21
22
|
(`mode=queue`) or injects steering into the running step (`mode=steer`), and
|
|
22
|
-
can optionally wait for the next reply.
|
|
23
|
+
can optionally wait for the next reply. An async send also returns a
|
|
24
|
+
`sinceSeq` anchor.
|
|
23
25
|
- **Wait for a reply or a segment.** `session_bridge_wait` blocks until new
|
|
24
|
-
assistant output appears after
|
|
25
|
-
as soon as a new **text** reply
|
|
26
|
-
soon as any new **completed output
|
|
27
|
-
text, reasoning, or tool-call turn),
|
|
28
|
-
you can observe output paragraph by
|
|
29
|
-
`requireTurnEnd` it additionally waits for
|
|
30
|
-
Timeout / abort return the partial result rather than
|
|
26
|
+
assistant output appears after `sinceSeq` (default: the latest event seq at
|
|
27
|
+
call time): `waitFor=reply` (default) returns as soon as a new **text** reply
|
|
28
|
+
is readable; `waitFor=segment` returns as soon as any new **completed output
|
|
29
|
+
step** appears (an `assistant/message` — text, reasoning, or tool-call turn),
|
|
30
|
+
*without* waiting for the whole turn, so you can observe output paragraph by
|
|
31
|
+
paragraph as it is produced. With `requireTurnEnd` it additionally waits for
|
|
32
|
+
the turn to settle. Timeout / abort return the partial result rather than
|
|
33
|
+
throwing. **An already-landed reply is never lost**: when no new output
|
|
34
|
+
arrives within the budget, the latest PRE-EXISTING reply/segment is returned
|
|
35
|
+
with `stale: true` (no more `(no text)`). To retrieve exactly "the reply to
|
|
36
|
+
what I sent", pass the `sinceSeq` returned by `session_bridge_send` /
|
|
37
|
+
`session_bridge_create` (works regardless of caller latency); `sinceSeq: -1`
|
|
38
|
+
counts existing events too, i.e. the anchor for a brand-new session.
|
|
31
39
|
- **Read any session.** `session_bridge_read` folds a session's event log into
|
|
32
40
|
readable rows — live or offline (from persistence) — with `sinceSeq` paging,
|
|
33
41
|
role filtering, and a `limit` (default 20, max 100).
|
|
@@ -44,7 +52,9 @@ action does.
|
|
|
44
52
|
working directory. Bridge-registered titles act as aliases.
|
|
45
53
|
- **Monitor and schedule a main task.** `session_bridge_status` reads a
|
|
46
54
|
session's real-time progress (running/idle, open turn, time since the last
|
|
47
|
-
event for stall detection, pending work, latest reply)
|
|
55
|
+
event for stall detection, pending work, latest reply); only a **running**
|
|
56
|
+
session is flagged `[STALLED]` (a quiet idle session is not stuck — same rule
|
|
57
|
+
as the watchdog). `session_bridge_cancel`
|
|
48
58
|
stops a running session. `session_bridge_monitor_start` runs a **background
|
|
49
59
|
watchdog loop** that polls the task, nudges it when it stalls, corrects it
|
|
50
60
|
when it drifts, terminates it after it stays stuck, and wraps up when it
|
|
@@ -68,7 +78,9 @@ action does.
|
|
|
68
78
|
| Making progress | Reset the stall counter (steady) |
|
|
69
79
|
|
|
70
80
|
The watchdog only treats **running** sessions as stalled, so a finished or idle
|
|
71
|
-
task is wrapped up rather than nudged forever
|
|
81
|
+
task is wrapped up rather than nudged forever (the `[STALLED]` marker in
|
|
82
|
+
`session_bridge_status` follows the same rule and is shown for running sessions
|
|
83
|
+
only). Logs go to
|
|
72
84
|
`~/.dsh/super-injector/dsh-session-bridge-monitor.log` (overridable).
|
|
73
85
|
|
|
74
86
|
Control it with `session_bridge_monitor_start` / `_stop` / `_list`.
|
|
@@ -122,6 +134,9 @@ bash scripts/build.sh && npm run build:client
|
|
|
122
134
|
# type-check src/ against the dsh that is actually installed (no checkout needed)
|
|
123
135
|
npm run check:compat
|
|
124
136
|
|
|
137
|
+
# regression tests for the wait/stall core logic (Node type stripping, no deps)
|
|
138
|
+
npm test
|
|
139
|
+
|
|
125
140
|
# via the injector toolchain
|
|
126
141
|
dev_build_plugin dsh-session-bridge
|
|
127
142
|
```
|
|
@@ -248,14 +263,14 @@ registration and junction; not re-assembled on restart).
|
|
|
248
263
|
|
|
249
264
|
| Tool | What it does |
|
|
250
265
|
|---|---|
|
|
251
|
-
| `session_bridge_create` | Create a main session (current or another workspace via `workspaceId` / `cwd`); optional first prompt + `waitForReply
|
|
252
|
-
| `session_bridge_send` | Send a message (`mode=queue`/`steer`); optional wait-for-reply. |
|
|
253
|
-
| `session_bridge_wait` | Wait for new output after `sinceSeq
|
|
266
|
+
| `session_bridge_create` | Create a main session (current or another workspace via `workspaceId` / `cwd`); optional first prompt + `waitForReply`; async creates return a `sinceSeq` anchor. |
|
|
267
|
+
| `session_bridge_send` | Send a message (`mode=queue`/`steer`); optional wait-for-reply; async sends return a `sinceSeq` anchor. |
|
|
268
|
+
| `session_bridge_wait` | Wait for new output after `sinceSeq` (default: latest seq at call time; `-1` counts existing events): `waitFor=reply` (text) or `waitFor=segment` (any completed step, no full-turn wait); optional `requireTurnEnd`; falls back to the pre-existing reply with `stale` when nothing new arrives. |
|
|
254
269
|
| `session_bridge_read` | Read messages — live or offline; `sinceSeq` paging, `role` filter, `limit`. |
|
|
255
270
|
| `session_bridge_segments` | Read completed output segments (each finished assistant step) incrementally by paragraph — live or offline. |
|
|
256
271
|
| `session_bridge_resume` | Bring a persisted session back online (idempotent). |
|
|
257
272
|
| `session_bridge_find` | Find sessions by title / id / workspace / directory across workspaces. |
|
|
258
|
-
| `session_bridge_status` | Read a session's live progress (running, open turn, stall detection, pending work, latest reply) plus live/finalized chain-of-thought (`reasoning` param). |
|
|
273
|
+
| `session_bridge_status` | Read a session's live progress (running, open turn, stall detection, pending work, latest reply) plus live/finalized chain-of-thought (`reasoning` param); `[STALLED]` is shown for running sessions only. |
|
|
259
274
|
| `session_bridge_cancel` | Stop a running session (abort active turn; clear queued/steering work unless `keepInbox`). |
|
|
260
275
|
| `session_bridge_monitor_start` | Start a background watchdog on a main session (poll, nudge, correct, cancel, wrap up); supports chain-of-thought `coRules` (e.g. reasoning not-contains "I'm" → cancel). |
|
|
261
276
|
| `session_bridge_monitor_stop` | Stop a watchdog (keep the session itself running). |
|
|
@@ -264,7 +279,7 @@ registration and junction; not re-assembled on restart).
|
|
|
264
279
|
| `session_bridge_archived` | List the archive set, optionally resolving titles. |
|
|
265
280
|
|
|
266
281
|
All tools return lossless JSON; wait-style tools never throw on timeout — they
|
|
267
|
-
return a `timedOut` / `aborted` flag.
|
|
282
|
+
return a `timedOut` / `aborted` / `stale` flag.
|
|
268
283
|
|
|
269
284
|
## Project layout
|
|
270
285
|
|
|
@@ -277,6 +292,7 @@ src/
|
|
|
277
292
|
registry.ts bridge-side title/workspace registry (~/.dsh/session-bridge-registry.json)
|
|
278
293
|
scripts/
|
|
279
294
|
build.sh type-check + link types against the DSH checkout
|
|
295
|
+
test-bridge-core.mjs wait/stall regression tests (npm test)
|
|
280
296
|
```
|
|
281
297
|
|
|
282
298
|
## Lifecycle and unloading
|
package/README.zh.md
CHANGED
|
@@ -11,14 +11,19 @@
|
|
|
11
11
|
|
|
12
12
|
- **创建真实 DSH 会话。** `session_bridge_create` 在当前工作区创建新的主会话(顶层 UI 会话),
|
|
13
13
|
传 `workspaceId` / `cwd` 则跨工作区;可选发送首条 prompt 并阻塞等待首条回复。provider / model /
|
|
14
|
-
reasoning effort
|
|
14
|
+
reasoning effort 默认继承调用会话。异步创建(不带 `waitForReply`)会返回 `sinceSeq` 锚点,
|
|
15
|
+
之后可用它精确地 `session_bridge_wait` 取回首条回复。
|
|
15
16
|
- **向任意会话发消息。** `session_bridge_send` 追加一轮(`mode=queue`)或向运行中的步骤注入
|
|
16
|
-
steering(`mode=steer
|
|
17
|
-
- **等待回复或段落。** `session_bridge_wait` 阻塞直至 `sinceSeq` 之后出现新的 assistant
|
|
18
|
-
`waitFor=reply
|
|
19
|
-
|
|
20
|
-
turn 结束,从而可以按段落逐段观察输出。开
|
|
21
|
-
|
|
17
|
+
steering(`mode=steer`),可选等待下一条回复;异步发送同样返回 `sinceSeq` 锚点。
|
|
18
|
+
- **等待回复或段落。** `session_bridge_wait` 阻塞直至 `sinceSeq` 之后出现新的 assistant 输出
|
|
19
|
+
(默认 `sinceSeq` = 调用时刻的最新事件 seq):`waitFor=reply`(默认)在有新的**文本**回复可读时
|
|
20
|
+
立即返回;`waitFor=segment` 在任意新**已完成输出步骤**出现时立即返回(一个 `assistant/message`
|
|
21
|
+
——文本、推理或工具调用段),*无需*等整个 turn 结束,从而可以按段落逐段观察输出。开
|
|
22
|
+
`requireTurnEnd` 则同时等待回合收尾。超时 / 中止返回部分结果,而非抛错。
|
|
23
|
+
**回复早已落地也不会丢**:预算内没等到新输出时,会返回预算前就存在的最新回复/段落并置
|
|
24
|
+
`stale: true`(不会再出现 `(no text)`)。要精确取回"某次发送之后的回复",把 `session_bridge_send` /
|
|
25
|
+
`session_bridge_create` 异步返回的 `sinceSeq` 作为锚点传进来即可(与调用方延迟无关);
|
|
26
|
+
`sinceSeq: -1` 表示"连既有事件也算",即新建会话的锚点。
|
|
22
27
|
- **读取任意会话。** `session_bridge_read` 把会话事件日志折叠为可读行——live 或离线(持久化)均可;
|
|
23
28
|
支持 `sinceSeq` 分页、role 过滤、`limit`(默认 20,最大 100)。
|
|
24
29
|
- **按段落读取输出。** `session_bridge_segments` 返回会话的**已完成输出段落**——每个已完成的
|
|
@@ -29,7 +34,8 @@
|
|
|
29
34
|
- **查找会话。** `session_bridge_find` 跨全部工作区按 标题 / id / workspace / 目录 匹配,返回
|
|
30
35
|
live/running 状态、标题、工作目录;bridge 登记的标题作为别名参与匹配。
|
|
31
36
|
- **监控并调度主任务。** `session_bridge_status` 读取会话实时进度(running/idle、是否 `openTurn`、
|
|
32
|
-
|
|
37
|
+
距最近事件毫秒数做卡住检测、待处理消息、最新回复);只有 **running** 会话才会被标 `[STALLED]`
|
|
38
|
+
(空闲会话没有进展是正常状态,与守护循环判定一致)。`session_bridge_cancel` 停止一个运行中的会话;
|
|
33
39
|
`session_bridge_monitor_start` 运行一个**后台守护循环**,轮询任务、卡住时催办、偏离时纠偏、
|
|
34
40
|
持续卡住则终止、完成即收尾。
|
|
35
41
|
- **归档会话。** `session_bridge_archive` 把会话加入 DSH workspace 归档集合(从所有分组视图隐藏,
|
|
@@ -48,7 +54,8 @@
|
|
|
48
54
|
| 连续卡住 ≥ `maxStuckCycles` | `cancel` 终止 |
|
|
49
55
|
| 正常推进 | 重置卡住计数(steady) |
|
|
50
56
|
|
|
51
|
-
守护只对 **running** 会话判定"卡住"
|
|
57
|
+
守护只对 **running** 会话判定"卡住",因此已完成/空闲的任务会被收尾而非无限催办
|
|
58
|
+
(`session_bridge_status` 的 `[STALLED]` 标注同理,只对 running 会话显示)。日志写入
|
|
52
59
|
`~/.dsh/super-injector/dsh-session-bridge-monitor.log`(可用 `logFile` 覆盖)。
|
|
53
60
|
|
|
54
61
|
用 `session_bridge_monitor_start` / `_stop` / `_list` 控制。
|
|
@@ -90,6 +97,9 @@ bash scripts/build.sh && npm run build:client
|
|
|
90
97
|
# 针对"实际安装的 dsh"做类型检查(不需要 checkout)
|
|
91
98
|
npm run check:compat
|
|
92
99
|
|
|
100
|
+
# 核心 wait/卡住判定的回归测试(Node 类型擦除直跑 src/core.ts,零依赖)
|
|
101
|
+
npm test
|
|
102
|
+
|
|
93
103
|
# 或经注入器工具链
|
|
94
104
|
dev_build_plugin dsh-session-bridge
|
|
95
105
|
```
|
|
@@ -194,14 +204,14 @@ dev_inject_plugin D:\code\dsh-session-bridge
|
|
|
194
204
|
|
|
195
205
|
| 工具 | 作用 |
|
|
196
206
|
|---|---|
|
|
197
|
-
| `session_bridge_create` | 创建主会话(当前或其它工作区,经 `workspaceId` / `cwd`);可选首条 prompt + `waitForReply
|
|
198
|
-
| `session_bridge_send` | 发消息(`mode=queue`/`steer
|
|
199
|
-
| `session_bridge_wait` | 等待 `sinceSeq`
|
|
207
|
+
| `session_bridge_create` | 创建主会话(当前或其它工作区,经 `workspaceId` / `cwd`);可选首条 prompt + `waitForReply`;异步时返回 `sinceSeq` 锚点。 |
|
|
208
|
+
| `session_bridge_send` | 发消息(`mode=queue`/`steer`);可选等待回复;异步时返回 `sinceSeq` 锚点。 |
|
|
209
|
+
| `session_bridge_wait` | 等待 `sinceSeq` 之后新输出(默认 = 调用时刻最新 seq,`-1` = 从头发算):`waitFor=reply`(文本)或 `waitFor=segment`(任一已完成步骤即返回,无需等整个 turn);可选 `requireTurnEnd`;零新输出时回落既有回复并置 `stale`。 |
|
|
200
210
|
| `session_bridge_read` | 读取消息 —— live 或离线;`sinceSeq` 分页、`role` 过滤、`limit`。 |
|
|
201
211
|
| `session_bridge_segments` | 增量读取已完成输出段落(每个已完成的 assistant 步骤)—— live 或离线。 |
|
|
202
212
|
| `session_bridge_resume` | 让持久化会话重新上线(幂等)。 |
|
|
203
213
|
| `session_bridge_find` | 跨工作区按 标题 / id / workspace / 目录 查找会话。 |
|
|
204
|
-
| `session_bridge_status` | 读取会话实时进度(running、openTurn、卡住检测、待处理、最新回复)及实时/已定型思维链(`reasoning`
|
|
214
|
+
| `session_bridge_status` | 读取会话实时进度(running、openTurn、卡住检测、待处理、最新回复)及实时/已定型思维链(`reasoning` 参数);`[STALLED]` 仅对 running 会话显示。 |
|
|
205
215
|
| `session_bridge_cancel` | 停止运行中的会话(中止活动 turn;`keepInbox` 保留排队/steering 输入)。 |
|
|
206
216
|
| `session_bridge_monitor_start` | 对一个主会话启动后台守护(轮询、催办、纠偏、终止、收尾);支持思维链 `coRules`(如 reasoning not-contains "I'm" → cancel)。 |
|
|
207
217
|
| `session_bridge_monitor_stop` | 停止守护(会话本身不终止)。 |
|
|
@@ -209,7 +219,7 @@ dev_inject_plugin D:\code\dsh-session-bridge
|
|
|
209
219
|
| `session_bridge_archive` | 归档会话(从分组隐藏;历史与位置保留)。 |
|
|
210
220
|
| `session_bridge_archived` | 列出归档集合,可选解析标题。 |
|
|
211
221
|
|
|
212
|
-
所有工具输出 lossless JSON;等待类工具超时不抛错,返回 `timedOut` / `aborted` 标记。
|
|
222
|
+
所有工具输出 lossless JSON;等待类工具超时不抛错,返回 `timedOut` / `aborted` / `stale` 标记。
|
|
213
223
|
|
|
214
224
|
## 项目结构
|
|
215
225
|
|
|
@@ -222,6 +232,7 @@ src/
|
|
|
222
232
|
registry.ts 桥侧标题/workspace 登记表(~/.dsh/session-bridge-registry.json)
|
|
223
233
|
scripts/
|
|
224
234
|
build.sh 类型检查 + 链接 DSH checkout 类型
|
|
235
|
+
test-bridge-core.mjs wait/卡住判定回归测试(npm test)
|
|
225
236
|
```
|
|
226
237
|
|
|
227
238
|
## 生命周期与卸载
|
package/dsh.plugin.json
CHANGED
package/lib/index.js
CHANGED
|
@@ -9535,8 +9535,11 @@ function sleep(ms) {
|
|
|
9535
9535
|
* 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
|
|
9536
9536
|
* 此时才会等到其后出现 turn/end。
|
|
9537
9537
|
*
|
|
9538
|
-
*
|
|
9539
|
-
*
|
|
9538
|
+
* 返回的 message:默认是 baseline 之后最新的一条**带文本** assistant 行;整个等待
|
|
9539
|
+
* 窗口内没有等到新文本回复时,回落为 baseline 及之前最新的一条带文本回复并置
|
|
9540
|
+
* stale:true(绝不把更晚的无文本中间行当回复返回 —— 那正是渲染层打印 "(no text)"
|
|
9541
|
+
* 的来源)。segment 模式同理:新段落优先,否则回落既有段落。
|
|
9542
|
+
* 超时/中止返回已收集内容;timedOut 表示"本次等待要求的输出未在预算内出现"。
|
|
9540
9543
|
*/
|
|
9541
9544
|
async function waitForReply(opts) {
|
|
9542
9545
|
const started = Date.now();
|
|
@@ -9573,7 +9576,25 @@ async function waitForReply(opts) {
|
|
|
9573
9576
|
if (Date.now() >= deadline) break;
|
|
9574
9577
|
await sleep(100);
|
|
9575
9578
|
}
|
|
9576
|
-
|
|
9579
|
+
const observedNew = waitForSegment ? segment !== null : textReply !== null;
|
|
9580
|
+
const sawNewRow = latest !== null;
|
|
9581
|
+
let stale = false;
|
|
9582
|
+
if (waitForSegment) {
|
|
9583
|
+
if (segment === null) {
|
|
9584
|
+
const prev = lastSegmentUpTo(sessionEvents(opts.session), opts.baselineSeq);
|
|
9585
|
+
if (prev !== null) {
|
|
9586
|
+
segment = prev;
|
|
9587
|
+
stale = true;
|
|
9588
|
+
}
|
|
9589
|
+
}
|
|
9590
|
+
} else if (textReply === null) {
|
|
9591
|
+
const prev = lastTextRowUpTo(foldMessages(sessionEvents(opts.session)), opts.baselineSeq);
|
|
9592
|
+
if (prev !== null) {
|
|
9593
|
+
textReply = prev;
|
|
9594
|
+
stale = true;
|
|
9595
|
+
}
|
|
9596
|
+
}
|
|
9597
|
+
const message = waitForSegment && segment !== null ? {
|
|
9577
9598
|
seq: segment.seq,
|
|
9578
9599
|
time: segment.time,
|
|
9579
9600
|
role: "assistant",
|
|
@@ -9581,16 +9602,34 @@ async function waitForReply(opts) {
|
|
|
9581
9602
|
...segment.text !== void 0 ? { text: segment.text } : {},
|
|
9582
9603
|
...segment.reasoning !== void 0 ? { reasoning: segment.reasoning } : {},
|
|
9583
9604
|
...segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}
|
|
9584
|
-
} : textReply
|
|
9605
|
+
} : textReply;
|
|
9585
9606
|
return {
|
|
9586
9607
|
message,
|
|
9587
9608
|
seq: message === null ? opts.baselineSeq : message.seq,
|
|
9588
9609
|
turnEnded,
|
|
9589
|
-
timedOut: requireTurnEnd ?
|
|
9610
|
+
timedOut: requireTurnEnd ? !(sawNewRow && turnEnded) : !observedNew,
|
|
9611
|
+
stale,
|
|
9590
9612
|
aborted: opts.signal !== void 0 && opts.signal.aborted,
|
|
9591
9613
|
waitedMs: Date.now() - started
|
|
9592
9614
|
};
|
|
9593
9615
|
}
|
|
9616
|
+
/** seq 及之前最新一个已完成输出段落(wait 零新输出时回落既有段落)。 */
|
|
9617
|
+
function lastSegmentUpTo(events, seq) {
|
|
9618
|
+
const segs = segmentsSince(events);
|
|
9619
|
+
for (let i = segs.length - 1; i >= 0; i -= 1) {
|
|
9620
|
+
const seg = segs[i];
|
|
9621
|
+
if (seg !== void 0 && seg.seq <= seq) return seg;
|
|
9622
|
+
}
|
|
9623
|
+
return null;
|
|
9624
|
+
}
|
|
9625
|
+
/** seq 及之前最新一条带文本的 assistant 行(wait 零新文本回复时回落既有回复)。 */
|
|
9626
|
+
function lastTextRowUpTo(rows, seq) {
|
|
9627
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
9628
|
+
const row = rows[i];
|
|
9629
|
+
if (row !== void 0 && row.role === "assistant" && row.text !== void 0 && row.seq <= seq) return row;
|
|
9630
|
+
}
|
|
9631
|
+
return null;
|
|
9632
|
+
}
|
|
9594
9633
|
/**
|
|
9595
9634
|
* 解析目标工作目录:workspaceId(注册表)> cwd(绝对路径/任意拼写)> 调用者会话 cwd。
|
|
9596
9635
|
* 跨工作区由 workspaceId / cwd 任意指定实现。
|
|
@@ -9642,6 +9681,14 @@ async function attachSessionToWorkspace(ctx, sessionId, cwd) {
|
|
|
9642
9681
|
return workspace.id;
|
|
9643
9682
|
}
|
|
9644
9683
|
/**
|
|
9684
|
+
* 卡住判定(唯一真源,status 渲染与监控 watchdog 共用):只有 **running** 会话才可能
|
|
9685
|
+
* "卡住" —— 空闲/已收尾的会话没有进展是正常状态,不能报 stall。阈值语义为"超过"
|
|
9686
|
+
* (严格大于),与工具描述/README 的 "more than stalledMs" 一致。
|
|
9687
|
+
*/
|
|
9688
|
+
function isStalled(running, stalledMs, thresholdMs) {
|
|
9689
|
+
return running === "running" && stalledMs !== null && stalledMs > thresholdMs;
|
|
9690
|
+
}
|
|
9691
|
+
/**
|
|
9645
9692
|
* 计算一个 live 会话的监控快照(agent 状态 + 事件折叠出的进度/卡住事实)。
|
|
9646
9693
|
* 纯读,无副作用。用于 `session_bridge_status`。
|
|
9647
9694
|
*/
|
|
@@ -9861,7 +9908,7 @@ var SessionMonitor = class {
|
|
|
9861
9908
|
this.stopWithLog(sessionId);
|
|
9862
9909
|
return;
|
|
9863
9910
|
}
|
|
9864
|
-
if (snapshot.running
|
|
9911
|
+
if (isStalled(snapshot.running, snapshot.stalledMs, entry.config.stalledMs ?? 6e4)) {
|
|
9865
9912
|
entry.stuckCount += 1;
|
|
9866
9913
|
entry.lastActionAt = Date.now();
|
|
9867
9914
|
if (this.shouldCancel(entry)) {
|
|
@@ -14141,6 +14188,14 @@ function liveAgent(env, sessionId) {
|
|
|
14141
14188
|
function liveAgents(env) {
|
|
14142
14189
|
return env.ctx.agents.list();
|
|
14143
14190
|
}
|
|
14191
|
+
/** 等待结果里的状态附注行(wait / send / create 三处渲染共用)。 */
|
|
14192
|
+
function waitNotes(reply) {
|
|
14193
|
+
const notes = [];
|
|
14194
|
+
if (reply.timedOut === true) notes.push("[wait timed out]");
|
|
14195
|
+
if (reply.stale === true) notes.push("[stale] pre-existing reply — nothing new arrived during the wait");
|
|
14196
|
+
if (reply.aborted === true) notes.push("[wait aborted]");
|
|
14197
|
+
return notes;
|
|
14198
|
+
}
|
|
14144
14199
|
/** 渲染等待结果为 JSON 友好值。 */
|
|
14145
14200
|
function renderWait(wait) {
|
|
14146
14201
|
const row = wait.message;
|
|
@@ -14156,6 +14211,7 @@ function renderWait(wait) {
|
|
|
14156
14211
|
seq: wait.seq,
|
|
14157
14212
|
turnEnded: wait.turnEnded,
|
|
14158
14213
|
timedOut: wait.timedOut,
|
|
14214
|
+
...wait.stale === true ? { stale: true } : {},
|
|
14159
14215
|
aborted: wait.aborted,
|
|
14160
14216
|
waitedMs: wait.waitedMs
|
|
14161
14217
|
};
|
|
@@ -14227,9 +14283,10 @@ function registerCreate(env) {
|
|
|
14227
14283
|
if (typeof v.cwd === "string") lines.push(`cwd: ${v.cwd}`);
|
|
14228
14284
|
if (typeof v.title === "string") lines.push(`title: ${v.title}`);
|
|
14229
14285
|
const reply = v.reply;
|
|
14286
|
+
if (typeof v.sinceSeq === "number") lines.push(`sinceSeq: ${String(v.sinceSeq)} (pass to session_bridge_wait)`);
|
|
14230
14287
|
if (reply !== void 0) {
|
|
14231
14288
|
lines.push(`reply: ${String(reply.message?.text ?? "(no text)")}`);
|
|
14232
|
-
|
|
14289
|
+
lines.push(...waitNotes(reply));
|
|
14233
14290
|
}
|
|
14234
14291
|
return [{
|
|
14235
14292
|
type: "text",
|
|
@@ -14296,8 +14353,10 @@ function registerCreate(env) {
|
|
|
14296
14353
|
source: "create"
|
|
14297
14354
|
});
|
|
14298
14355
|
let reply;
|
|
14356
|
+
let createBaseline;
|
|
14299
14357
|
if (typeof args.prompt === "string" && args.prompt.trim() !== "") {
|
|
14300
14358
|
const baseline = maxSeq(sessionEvents(agent.session));
|
|
14359
|
+
createBaseline = baseline;
|
|
14301
14360
|
agent.followup(userMessage(args.prompt.trim()));
|
|
14302
14361
|
env.registry.touch(sessionId);
|
|
14303
14362
|
if (args.waitForReply === true) reply = await maybeWait(env, agent.session, baseline, args, exec.signal);
|
|
@@ -14307,6 +14366,7 @@ function registerCreate(env) {
|
|
|
14307
14366
|
cwd: targetCwd,
|
|
14308
14367
|
...workspaceId === void 0 ? {} : { workspaceId },
|
|
14309
14368
|
...typeof args.title === "string" && args.title.trim() !== "" ? { title: args.title.trim() } : {},
|
|
14369
|
+
...reply === void 0 && createBaseline !== void 0 ? { sinceSeq: createBaseline } : {},
|
|
14310
14370
|
...reply === void 0 ? {} : { reply }
|
|
14311
14371
|
});
|
|
14312
14372
|
}
|
|
@@ -14350,9 +14410,10 @@ function registerSend(env) {
|
|
|
14350
14410
|
const v = value;
|
|
14351
14411
|
const reply = v.reply;
|
|
14352
14412
|
const lines = ["sent to " + String(v.sessionId)];
|
|
14413
|
+
if (typeof v.sinceSeq === "number") lines.push("sinceSeq: " + String(v.sinceSeq) + " (pass to session_bridge_wait)");
|
|
14353
14414
|
if (reply !== null && reply !== void 0) {
|
|
14354
14415
|
lines.push("reply: " + String(reply.message?.text ?? "(no text)"));
|
|
14355
|
-
|
|
14416
|
+
lines.push(...waitNotes(reply));
|
|
14356
14417
|
}
|
|
14357
14418
|
return [{
|
|
14358
14419
|
type: "text",
|
|
@@ -14378,7 +14439,7 @@ function registerSend(env) {
|
|
|
14378
14439
|
return asJson({
|
|
14379
14440
|
accepted: true,
|
|
14380
14441
|
sessionId: args.sessionId,
|
|
14381
|
-
...reply === void 0 ? {} : { reply }
|
|
14442
|
+
...reply === void 0 ? { sinceSeq: baseline } : { reply }
|
|
14382
14443
|
});
|
|
14383
14444
|
}
|
|
14384
14445
|
}));
|
|
@@ -14473,7 +14534,7 @@ function registerResume(env) {
|
|
|
14473
14534
|
function registerWait(env) {
|
|
14474
14535
|
env.ctx.tools.register(defineTool({
|
|
14475
14536
|
name: "session_bridge_wait",
|
|
14476
|
-
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).",
|
|
14537
|
+
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.",
|
|
14477
14538
|
parameters: {
|
|
14478
14539
|
sessionId: {
|
|
14479
14540
|
type: "string",
|
|
@@ -14482,7 +14543,7 @@ function registerWait(env) {
|
|
|
14482
14543
|
},
|
|
14483
14544
|
sinceSeq: {
|
|
14484
14545
|
type: "number",
|
|
14485
|
-
description: "Only replies after this event seq count (default: latest seq at call time)."
|
|
14546
|
+
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)."
|
|
14486
14547
|
},
|
|
14487
14548
|
timeoutMs: {
|
|
14488
14549
|
type: "number",
|
|
@@ -14510,10 +14571,9 @@ function registerWait(env) {
|
|
|
14510
14571
|
text: "no reply observed"
|
|
14511
14572
|
}];
|
|
14512
14573
|
const message = reply.message;
|
|
14513
|
-
const lines = ["reply
|
|
14574
|
+
const lines = [message === null ? "no new reply within " + String(reply.waitedMs ?? "?") + "ms (no assistant text in this session)" : "reply seq " + String(reply.seq) + ": " + String(message.text ?? "(no text)")];
|
|
14514
14575
|
if (typeof reply.turnEnded === "boolean") lines.push("turnEnded: " + String(reply.turnEnded));
|
|
14515
|
-
|
|
14516
|
-
if (reply.aborted === true) lines.push("[wait aborted]");
|
|
14576
|
+
lines.push(...waitNotes(reply));
|
|
14517
14577
|
return [{
|
|
14518
14578
|
type: "text",
|
|
14519
14579
|
text: lines.join("\n")
|
|
@@ -14524,28 +14584,19 @@ function registerWait(env) {
|
|
|
14524
14584
|
if (typeof args.sessionId !== "string" || args.sessionId.trim() === "") throw new Error("invalid sessionId: expected a non-empty string");
|
|
14525
14585
|
const agent = liveAgent(env, args.sessionId);
|
|
14526
14586
|
if (agent === void 0) throw new Error("session " + JSON.stringify(args.sessionId) + " is not live — call session_bridge_resume first (waiting requires a live session)");
|
|
14527
|
-
const
|
|
14528
|
-
let baseline;
|
|
14529
|
-
if (typeof args.sinceSeq === "number" && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) baseline = args.sinceSeq;
|
|
14530
|
-
else if (waitSegment) {
|
|
14531
|
-
const segs = segmentsSince(sessionEvents(agent.session));
|
|
14532
|
-
baseline = segs.length === 0 ? -1 : segs[segs.length - 1]?.seq ?? -1;
|
|
14533
|
-
} else {
|
|
14534
|
-
let lastText = -1;
|
|
14535
|
-
for (const row of foldMessages(sessionEvents(agent.session))) if (row.text !== void 0) lastText = row.seq;
|
|
14536
|
-
baseline = lastText;
|
|
14537
|
-
}
|
|
14587
|
+
const baseline = typeof args.sinceSeq === "number" && Number.isInteger(args.sinceSeq) && args.sinceSeq >= -1 ? args.sinceSeq : maxSeq(sessionEvents(agent.session));
|
|
14538
14588
|
const result = await waitForReply({
|
|
14539
14589
|
session: agent.session,
|
|
14540
14590
|
baselineSeq: baseline,
|
|
14541
14591
|
timeoutMs: clampTimeout(args.timeoutMs),
|
|
14542
14592
|
signal: exec.signal,
|
|
14543
14593
|
requireTurnEnd: args.requireTurnEnd === true,
|
|
14544
|
-
...
|
|
14594
|
+
...args.waitFor === "segment" ? { waitForSegment: true } : {}
|
|
14545
14595
|
});
|
|
14546
14596
|
env.registry.touch(args.sessionId);
|
|
14547
14597
|
return asJson({
|
|
14548
14598
|
sessionId: args.sessionId,
|
|
14599
|
+
sinceSeq: baseline,
|
|
14549
14600
|
reply: renderWait(result),
|
|
14550
14601
|
running: agent.status === "running"
|
|
14551
14602
|
});
|
|
@@ -15151,7 +15202,11 @@ function pruneReasoning(snapshot, mode) {
|
|
|
15151
15202
|
else delete rest.lastReasoning;
|
|
15152
15203
|
return rest;
|
|
15153
15204
|
}
|
|
15154
|
-
/**
|
|
15205
|
+
/**
|
|
15206
|
+
* 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。
|
|
15207
|
+
* 卡住判定走 core.isStalled(唯一真源):只有 running 会话才可能被标 [STALLED] ——
|
|
15208
|
+
* 空闲/已收尾的会话"很久没事件"是正常状态,不是卡住(与监控 watchdog 一致)。
|
|
15209
|
+
*/
|
|
15155
15210
|
function renderStatus(snapshot, stalledMsThreshold) {
|
|
15156
15211
|
const lines = [];
|
|
15157
15212
|
const runLabel = snapshot.running === "running" ? "running" : "idle";
|
|
@@ -15159,7 +15214,7 @@ function renderStatus(snapshot, stalledMsThreshold) {
|
|
|
15159
15214
|
if (snapshot.openTurn) lines.push(`openTurn: yes (turn #${snapshot.lastTurn})`);
|
|
15160
15215
|
else lines.push(`openTurn: no (last turn #${snapshot.lastTurn})`);
|
|
15161
15216
|
if (snapshot.stalledMs !== null) {
|
|
15162
|
-
const stalled = snapshot.stalledMs
|
|
15217
|
+
const stalled = isStalled(snapshot.running, snapshot.stalledMs, stalledMsThreshold);
|
|
15163
15218
|
lines.push(`lastActivity: now-${snapshot.stalledMs}ms${stalled ? " [STALLED]" : ""}`);
|
|
15164
15219
|
}
|
|
15165
15220
|
if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`);
|
|
@@ -15173,7 +15228,7 @@ function renderStatus(snapshot, stalledMsThreshold) {
|
|
|
15173
15228
|
function registerStatus(env) {
|
|
15174
15229
|
env.ctx.tools.register(defineTool({
|
|
15175
15230
|
name: "session_bridge_status",
|
|
15176
|
-
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
|
|
15231
|
+
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.",
|
|
15177
15232
|
parameters: {
|
|
15178
15233
|
sessionId: {
|
|
15179
15234
|
type: "string",
|
|
@@ -15182,7 +15237,7 @@ function registerStatus(env) {
|
|
|
15182
15237
|
},
|
|
15183
15238
|
stalledMsThreshold: {
|
|
15184
15239
|
type: "number",
|
|
15185
|
-
description: "Mark
|
|
15240
|
+
description: "Mark a RUNNING session STALLED when time since the last event exceeds this many ms (default 60000); idle sessions are never marked."
|
|
15186
15241
|
},
|
|
15187
15242
|
recent: {
|
|
15188
15243
|
type: "number",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-bridge",
|
|
3
|
-
"version": "0.3.2-alpha.
|
|
3
|
+
"version": "0.3.2-alpha.3",
|
|
4
4
|
"description": "会话桥(dsh-session-bridge):通过提示词创建新的主会话(顶层 UI 会话)、向任意会话发送消息、等待会话的下一条回复、读取会话消息,并按会话名或 id 跨工作区查找会话;此外支持监控/调度主任务与归档会话。",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"build": "bash scripts/build.sh",
|
|
84
84
|
"build:client": "tsdown",
|
|
85
85
|
"check:compat": "node scripts/check-dsh-compat.mjs",
|
|
86
|
+
"test": "node --experimental-strip-types scripts/test-bridge-core.mjs",
|
|
86
87
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
87
88
|
}
|
|
88
89
|
}
|
|
@@ -79,6 +79,13 @@ function resolveScopeDir(argv) {
|
|
|
79
79
|
}
|
|
80
80
|
} catch { /* dsh not on PATH; the other anchors decide */ }
|
|
81
81
|
|
|
82
|
+
// Anchor 3 (last resort): this repository's own install. CI pins the
|
|
83
|
+
// @deepseek-ai packages through pnpm overrides and installs no DSH app, so
|
|
84
|
+
// the scope only exists here; probing it keeps check:compat runnable in CI.
|
|
85
|
+
// A developer machine resolves one of the anchors above first, so the
|
|
86
|
+
// *running* harness keeps priority there.
|
|
87
|
+
candidates.push(join(ROOT, 'node_modules', '@deepseek-ai'))
|
|
88
|
+
|
|
82
89
|
for (const candidate of candidates) if (isScopeDir(candidate)) return candidate
|
|
83
90
|
return undefined
|
|
84
91
|
}
|
|
@@ -115,6 +122,13 @@ function dshVersionOf(scopeDir) {
|
|
|
115
122
|
const manifest = readJson(candidate)
|
|
116
123
|
if (manifest?.name === '@deepseek-ai/dsh' && typeof manifest.version === 'string') return manifest.version
|
|
117
124
|
}
|
|
125
|
+
// Repo-local install (CI overrides): there is no `dsh` app package, but the
|
|
126
|
+
// release versions every dsh-* package together, so any of them names the
|
|
127
|
+
// harness version the types and the bundle were resolved from.
|
|
128
|
+
for (const pkg of ['dsh-tools', 'dsh-session', 'dsh-agent']) {
|
|
129
|
+
const manifest = readJson(join(scopeDir, pkg, 'package.json'))
|
|
130
|
+
if (typeof manifest?.version === 'string') return manifest.version
|
|
131
|
+
}
|
|
118
132
|
return undefined
|
|
119
133
|
}
|
|
120
134
|
|
|
@@ -142,8 +156,11 @@ function artifactProvenance() {
|
|
|
142
156
|
// Source checkout: <root>/packages/... or <root>/vendor/... (relative to the map in lib/).
|
|
143
157
|
const checkout = source.match(/^(.*?)[/\\](?:packages|vendor)[/\\]/)
|
|
144
158
|
if (checkout !== null) { roots.add(resolve(ROOT, 'lib', checkout[1])); continue }
|
|
145
|
-
// Registry install: .../.pnpm/@deepseek-ai
|
|
146
|
-
|
|
159
|
+
// Registry install: .../.pnpm/@deepseek-ai+dsh-<name>@<version>[_hash]/node_modules/...
|
|
160
|
+
// Only dsh-* packages name the harness release; sibling @deepseek-ai
|
|
161
|
+
// packages (cordis, schemastery) are versioned independently, so counting
|
|
162
|
+
// them would make every CI build look like a provenance mismatch.
|
|
163
|
+
const registry = source.match(/[\\/]\.pnpm[\\/]@deepseek-ai\+dsh-[a-z0-9-]+@([^\\/]+)[\\/]node_modules/)
|
|
147
164
|
if (registry !== null) {
|
|
148
165
|
const version = registry[1].replace(/_.*$/, '')
|
|
149
166
|
if (/^\d/.test(version)) versions.add(version)
|
|
@@ -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
|
}
|
|
@@ -429,8 +435,11 @@ export interface WaitForReplyOptions {
|
|
|
429
435
|
* 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
|
|
430
436
|
* 此时才会等到其后出现 turn/end。
|
|
431
437
|
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
438
|
+
* 返回的 message:默认是 baseline 之后最新的一条**带文本** assistant 行;整个等待
|
|
439
|
+
* 窗口内没有等到新文本回复时,回落为 baseline 及之前最新的一条带文本回复并置
|
|
440
|
+
* stale:true(绝不把更晚的无文本中间行当回复返回 —— 那正是渲染层打印 "(no text)"
|
|
441
|
+
* 的来源)。segment 模式同理:新段落优先,否则回落既有段落。
|
|
442
|
+
* 超时/中止返回已收集内容;timedOut 表示"本次等待要求的输出未在预算内出现"。
|
|
434
443
|
*/
|
|
435
444
|
export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWaitResult> {
|
|
436
445
|
const started = Date.now()
|
|
@@ -466,23 +475,60 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
|
|
|
466
475
|
if (Date.now() >= deadline) break
|
|
467
476
|
await sleep(100)
|
|
468
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
|
+
}
|
|
469
493
|
// 段落模式返回该段(把 reasoning 并进返回行,便于“按段落读思维链”);否则返回最新文本行。
|
|
470
|
-
|
|
494
|
+
const message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
|
|
471
495
|
seq: segment.seq, time: segment.time, role: 'assistant', images: 0,
|
|
472
496
|
...(segment.text !== undefined ? { text: segment.text } : {}),
|
|
473
497
|
...(segment.reasoning !== undefined ? { reasoning: segment.reasoning } : {}),
|
|
474
498
|
...(segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}),
|
|
475
|
-
} :
|
|
499
|
+
} : textReply
|
|
476
500
|
return {
|
|
477
501
|
message,
|
|
478
502
|
seq: message === null ? opts.baselineSeq : message.seq,
|
|
479
503
|
turnEnded,
|
|
480
|
-
|
|
504
|
+
// done 条件未达成即超时。requireTurnEnd 下"连新行都没出现"同样算超时
|
|
505
|
+
// (旧写法 latest !== null && !turnEnded 会把这种情况误报为未超时)。
|
|
506
|
+
timedOut: requireTurnEnd ? !(sawNewRow && turnEnded) : !observedNew,
|
|
507
|
+
stale,
|
|
481
508
|
aborted: opts.signal !== undefined && opts.signal.aborted,
|
|
482
509
|
waitedMs: Date.now() - started,
|
|
483
510
|
}
|
|
484
511
|
}
|
|
485
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
|
+
|
|
486
532
|
export interface TargetCwdArgs {
|
|
487
533
|
workspaceId?: string
|
|
488
534
|
cwd?: string
|
|
@@ -550,6 +596,15 @@ export async function attachSessionToWorkspace(ctx: Context, sessionId: SessionI
|
|
|
550
596
|
return workspace.id
|
|
551
597
|
}
|
|
552
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
|
+
|
|
553
608
|
/** 一次会话监控快照:供"监控线程"判断主任务是否在跑、有无进展、是否卡住。 */
|
|
554
609
|
export interface BridgeStatusSnapshot {
|
|
555
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
|
},
|