@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0
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 +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
package/src/streamer.ts
ADDED
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn streaming card state: folds live session events into card snapshots
|
|
3
|
+
* and patches the Feishu card on a fixed refresh interval (default 500 ms).
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle per (session, user message):
|
|
6
|
+
* 1. `attach()` — the reply card was just sent; bind it to the session as
|
|
7
|
+
* segment 1.
|
|
8
|
+
* 2. `session/event` consumption — text deltas fill the body, reasoning
|
|
9
|
+
* deltas fill the fold panel, usage/header events advance the stats fold,
|
|
10
|
+
* and tool-call / workflow-record events feed the two transient activity
|
|
11
|
+
* lines: the delegation status line (子代理 / 工作流 progress, settled
|
|
12
|
+
* workflow outcome, heartbeat) and the tool line (the parent's in-flight
|
|
13
|
+
* tool call). The lines are INDEPENDENT — a later tool call never
|
|
14
|
+
* supersedes the settled workflow status. Subagent children live in their
|
|
15
|
+
* own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
|
|
16
|
+
* events are folded into the parent turn's status line through
|
|
17
|
+
* `session.header.parentSession` (status only — the child's text output
|
|
18
|
+
* never reaches the card).
|
|
19
|
+
* 3. When the body outgrows one card's budget, the overflow is split at a
|
|
20
|
+
* clean breakpoint and sent as a continuation segment card (segment 2, 3,
|
|
21
|
+
* …) into the same topic; the stream then keeps flowing into the newest
|
|
22
|
+
* card. Reasoning is never segmented — it stays on card 1 and is clipped
|
|
23
|
+
* to the leftover budget (body has priority).
|
|
24
|
+
* 4. `turnEnded(turn)` — finalize every segment card (`回复结束` / `回复失败`)
|
|
25
|
+
* and request the Typing → DONE/No reaction swap on the triggering message.
|
|
26
|
+
*
|
|
27
|
+
* The first visible text delta flips the panel title 推理中 → 回复中; the
|
|
28
|
+
* final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
|
|
29
|
+
* only runs when the content actually changed. Segment sends are serialized
|
|
30
|
+
* per session so cards arrive in order.
|
|
31
|
+
*
|
|
32
|
+
* Keep-alive: turns that produce no text deltas for a while (tool calls,
|
|
33
|
+
* subagent/workflow runs, provider-side work) are kept visibly alive by the
|
|
34
|
+
* activity lines, whose elapsed counters are re-rendered on a slow heartbeat
|
|
35
|
+
* (`heartbeatMs`, default 5 s) — the card never sits silently on a live turn.
|
|
36
|
+
*
|
|
37
|
+
* @module dsh-channels/streamer
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
|
|
41
|
+
import { buildCard, segmentCardContent } from './cards.ts'
|
|
42
|
+
import type { CardState, PanelPhase } from './cards.ts'
|
|
43
|
+
import type { FeishuChannel } from './feishu-channel.ts'
|
|
44
|
+
import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
|
|
45
|
+
import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compact build marker printed by the service at startup so a deployment's
|
|
49
|
+
* console can confirm which build of the activity status line is loaded.
|
|
50
|
+
* Bump on every behavior change to this feature.
|
|
51
|
+
*/
|
|
52
|
+
export const ACTIVITY_BUILD_MARKER = 'activity-status-v6'
|
|
53
|
+
|
|
54
|
+
/** How a segment card reached Feishu. */
|
|
55
|
+
export interface SegmentDelivery {
|
|
56
|
+
messageId: string
|
|
57
|
+
/** `card` segments stream via `updateCard`; `text` messages are final
|
|
58
|
+
* (delivered as a plain-text fallback and never patched afterwards). */
|
|
59
|
+
mode: 'card' | 'text'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Card figures read from host projections (all optional: unit may be unmounted). */
|
|
63
|
+
export interface ProjectedStats {
|
|
64
|
+
inputTokens?: number
|
|
65
|
+
outputTokens?: number
|
|
66
|
+
cacheHitPct?: number
|
|
67
|
+
contextWindow?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface StreamerHooks {
|
|
71
|
+
/** Fetch current context pressure for one session (may be expensive → cached by caller). */
|
|
72
|
+
measureContext(session: Session): { contextTokens?: number; contextWindow?: number }
|
|
73
|
+
/**
|
|
74
|
+
* Host-projection-backed figures for one session. When present these win
|
|
75
|
+
* over the local incremental fold (the host units fold the durable embedded
|
|
76
|
+
* assistant streams the plugin cannot see as live events).
|
|
77
|
+
*/
|
|
78
|
+
projectStats?(session: Session): ProjectedStats | undefined
|
|
79
|
+
/** Resolve the session id label shown on the card. */
|
|
80
|
+
sessionLabel(sessionId: string): string
|
|
81
|
+
/** Log a non-fatal streaming error. */
|
|
82
|
+
onError(sessionId: string, error: unknown): void
|
|
83
|
+
/**
|
|
84
|
+
* Invoked once when a turn's card reaches its final state. Carries the main
|
|
85
|
+
* card's message id so the caller can retire the turn's in-flight repair
|
|
86
|
+
* snapshot (the id is the snapshot's storage key).
|
|
87
|
+
*/
|
|
88
|
+
onTurnFinalized(sessionId: string, turn: number, mainCardMessageId: string): void
|
|
89
|
+
/**
|
|
90
|
+
* Invoked when a continuation segment's message id resolves (delivery
|
|
91
|
+
* succeeded). Lets the caller fold the resolved id into the turn's repair
|
|
92
|
+
* snapshot. `segmentIndex` is the card index within the turn (`0` is the
|
|
93
|
+
* main card; never reported).
|
|
94
|
+
*/
|
|
95
|
+
onSegmentDelivered?(
|
|
96
|
+
mainCardMessageId: string,
|
|
97
|
+
segmentIndex: number,
|
|
98
|
+
messageId: string,
|
|
99
|
+
mode: SegmentDelivery['mode'],
|
|
100
|
+
): void
|
|
101
|
+
/**
|
|
102
|
+
* Send one continuation segment card for a turn (calls arrive in segment
|
|
103
|
+
* order per session). Implementations may fall back to a plain-text message
|
|
104
|
+
* (reporting `mode: 'text'`) when the card is rejected — e.g. Feishu's
|
|
105
|
+
* per-card table limit (error 11310) — so the content always reaches the
|
|
106
|
+
* user. `fallbackText` carries the raw segment body for that fallback.
|
|
107
|
+
*/
|
|
108
|
+
sendSegmentCard(
|
|
109
|
+
sessionId: string,
|
|
110
|
+
segment: number,
|
|
111
|
+
card: object,
|
|
112
|
+
fallbackText: string,
|
|
113
|
+
): Promise<SegmentDelivery>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One segment card of a turn. Card 1 is the attached reply card; further
|
|
117
|
+
* cards are created lazily when the body overflows the previous card. */
|
|
118
|
+
interface SegmentCard {
|
|
119
|
+
/** Feishu message id; undefined until the segment send resolves. */
|
|
120
|
+
messageId: string | undefined
|
|
121
|
+
/** Body accumulated for this segment (frozen once a later segment exists). */
|
|
122
|
+
body: string
|
|
123
|
+
/** True when the card has unsent content for its message id. */
|
|
124
|
+
dirty: boolean
|
|
125
|
+
/** True once this card stopped accepting body growth. */
|
|
126
|
+
frozen: boolean
|
|
127
|
+
/** Delivery mode once known; `text` segments are never patched. */
|
|
128
|
+
mode: SegmentDelivery['mode'] | undefined
|
|
129
|
+
/** True when delivery failed permanently (logged; never retried again). */
|
|
130
|
+
failed: boolean
|
|
131
|
+
/** True while a segment send is enqueued/in flight (prevents duplicate sends). */
|
|
132
|
+
sending: boolean
|
|
133
|
+
/** Consecutive update failures (drives the retry backoff). */
|
|
134
|
+
failures: number
|
|
135
|
+
/** Earliest timestamp for the next update attempt. */
|
|
136
|
+
nextRetryAt: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface TurnState {
|
|
140
|
+
/** Turn number once known; undefined until the first `step/start`. */
|
|
141
|
+
turn: number | undefined
|
|
142
|
+
/** Message whose reaction carries Typing → DONE/No. */
|
|
143
|
+
triggerMessageId: string
|
|
144
|
+
/** Reaction id of the Typing reaction (undefined when the add failed). */
|
|
145
|
+
reactionId: string | undefined
|
|
146
|
+
phase: PanelPhase
|
|
147
|
+
reasoning: string
|
|
148
|
+
/** Request header produced during THIS turn; never inherited from history. */
|
|
149
|
+
header: HeaderSample | undefined
|
|
150
|
+
/** Provider usage produced during THIS turn; never inherited from history. */
|
|
151
|
+
lastUsage: UsageSample | undefined
|
|
152
|
+
/** Structured terminal failure for this turn, when `turn/end.kind=error`. */
|
|
153
|
+
failure: { message: string; code?: string } | undefined
|
|
154
|
+
/** Segment cards in order; `cards[0]` is the attached reply card. */
|
|
155
|
+
cards: SegmentCard[]
|
|
156
|
+
dirty: boolean
|
|
157
|
+
lastPatchAt: number
|
|
158
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
159
|
+
finalized: boolean
|
|
160
|
+
/** Set by `detach()`; pending sends then stop scheduling further patches. */
|
|
161
|
+
disposed: boolean
|
|
162
|
+
/** Unix ms when the turn was attached (heartbeat elapsed base). */
|
|
163
|
+
turnStartedAt: number
|
|
164
|
+
/** The in-flight tool call, if any (cleared by its `tool/result`). */
|
|
165
|
+
toolActivity: { name: string; label: string; since: number } | undefined
|
|
166
|
+
/** Live workflow-run folds projected by the workflow tool into this session. */
|
|
167
|
+
workflowRuns: Map<string, WorkflowRun>
|
|
168
|
+
/** The last settled workflow run (kept visible until the next activity). */
|
|
169
|
+
workflowEndStatus: { name: string; stopReason: string } | undefined
|
|
170
|
+
/**
|
|
171
|
+
* Live subagent child sessions folded into this turn (child session id →
|
|
172
|
+
* status). Children are discovered through `session.header.parentSession`
|
|
173
|
+
* and removed when their own turn ends.
|
|
174
|
+
*/
|
|
175
|
+
children: Map<string, ChildActivity>
|
|
176
|
+
/**
|
|
177
|
+
* Labels of subagent children that FINISHED during this turn (capped). They
|
|
178
|
+
* leave the live `children` fold at their turn/end but are summarized on
|
|
179
|
+
* the settled card: `🤖 子代理完成:a、b`.
|
|
180
|
+
*/
|
|
181
|
+
settled: string[]
|
|
182
|
+
/** Currently rendered delegation status line (recomputed on every snapshot). */
|
|
183
|
+
status: string
|
|
184
|
+
/** Kind of the status line currently logged (change-detection only). */
|
|
185
|
+
statusKind: string
|
|
186
|
+
/** Currently rendered tool-call line (recomputed on every snapshot). */
|
|
187
|
+
tool: string
|
|
188
|
+
/** Kind of the tool line currently logged (change-detection only). */
|
|
189
|
+
toolKind: string
|
|
190
|
+
/** Slow keep-alive timer re-rendering the activity elapsed counter. */
|
|
191
|
+
heartbeatTimer: ReturnType<typeof setTimeout> | undefined
|
|
192
|
+
/**
|
|
193
|
+
* Rollback marks for live stream attempts (`attemptId` → lengths at the
|
|
194
|
+
* attempt's `start` frame). An `abandoned` attempt's deltas never reach the
|
|
195
|
+
* durable model history, so the card must drop them again.
|
|
196
|
+
*/
|
|
197
|
+
attempts: Map<string, AttemptMark>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Card/body lengths captured at one stream attempt's `start` frame. */
|
|
201
|
+
interface AttemptMark {
|
|
202
|
+
cardCount: number
|
|
203
|
+
bodyLengths: number[]
|
|
204
|
+
reasoningLength: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One live `tool-workflow/run-*` fold: run identity and member progress. */
|
|
208
|
+
interface WorkflowRun {
|
|
209
|
+
name: string
|
|
210
|
+
startedAt: number
|
|
211
|
+
/** Members started so far. */
|
|
212
|
+
started: number
|
|
213
|
+
/** Members settled so far. */
|
|
214
|
+
ended: number
|
|
215
|
+
/** Still-running members (started minus ended), in start order. */
|
|
216
|
+
active: Array<{ seq: number; label: string }>
|
|
217
|
+
/** Latest phase declared by a member (`phase()` in the workflow script). */
|
|
218
|
+
phase?: string
|
|
219
|
+
/**
|
|
220
|
+
* Child session ids of this run's members — their own events are already
|
|
221
|
+
* aggregated by the workflow line and must not be folded again as generic
|
|
222
|
+
* children.
|
|
223
|
+
*/
|
|
224
|
+
memberIds: Set<string>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One running subagent child's displayable status. */
|
|
228
|
+
interface ChildActivity {
|
|
229
|
+
/** Delegation description (subagent/descriptor label), default 子代理. */
|
|
230
|
+
label: string
|
|
231
|
+
/** The child's latest tool call, if any. */
|
|
232
|
+
tool?: string
|
|
233
|
+
/** When the child was first observed (elapsed base). */
|
|
234
|
+
since: number
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Hard cap on the activity line so it can never threaten the card budget. */
|
|
238
|
+
const ACTIVITY_MAX_CHARS = 64
|
|
239
|
+
|
|
240
|
+
/** Trim an activity line to `ACTIVITY_MAX_CHARS` (ellipsis counts as one char). */
|
|
241
|
+
function clipActivity(line: string): string {
|
|
242
|
+
return line.length <= ACTIVITY_MAX_CHARS ? line : `${line.slice(0, ACTIVITY_MAX_CHARS - 1)}…`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `已运行 45s` / `已运行 2分5s` — the activity line's elapsed counter. */
|
|
246
|
+
function fmtElapsed(ms: number): string {
|
|
247
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
248
|
+
if (total < 60) return `已运行 ${total}s`
|
|
249
|
+
return `已运行 ${Math.floor(total / 60)}分${total % 60}s`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Human label for one tool call: the subagent tool contributes its
|
|
254
|
+
* `description`, the workflow tool its `meta.name`; anything else uses the
|
|
255
|
+
* bare tool name. Arguments are model-produced JSON — parsing failures fall
|
|
256
|
+
* back to the tool name instead of failing the fold.
|
|
257
|
+
*/
|
|
258
|
+
function toolActivityLabel(name: string, rawArguments: string): string {
|
|
259
|
+
if (name !== 'subagent' && name !== 'workflow') return name
|
|
260
|
+
try {
|
|
261
|
+
const args = JSON.parse(rawArguments) as Record<string, unknown>
|
|
262
|
+
if (name === 'subagent' && typeof args.description === 'string' && args.description !== '') {
|
|
263
|
+
return args.description
|
|
264
|
+
}
|
|
265
|
+
if (name === 'workflow') {
|
|
266
|
+
const meta = args.meta as Record<string, unknown> | undefined
|
|
267
|
+
if (meta !== undefined && typeof meta.name === 'string' && meta.name !== '') return meta.name
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Unparseable arguments: fall back to the bare tool name.
|
|
271
|
+
}
|
|
272
|
+
return name
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `completed`/`failed`/`cancelled` → the Chinese line fragment. */
|
|
276
|
+
const WORKFLOW_OUTCOMES: Record<string, string> = {
|
|
277
|
+
completed: '完成',
|
|
278
|
+
failed: '失败',
|
|
279
|
+
cancelled: '已取消',
|
|
280
|
+
error: '失败',
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export class TurnStreamer {
|
|
284
|
+
private readonly channel: FeishuChannel
|
|
285
|
+
private readonly refreshMs: number
|
|
286
|
+
private readonly hooks: StreamerHooks
|
|
287
|
+
/** Slow keep-alive interval for the activity line's elapsed counter. */
|
|
288
|
+
private readonly heartbeatMs: number
|
|
289
|
+
/** sessionId → queue of turn states (FIFO consumption of the driver). */
|
|
290
|
+
private readonly bySession = new Map<string, TurnState[]>()
|
|
291
|
+
/** sessionId → session-log stats fold (session cumulative). */
|
|
292
|
+
private readonly folds = new Map<string, StatsFold>()
|
|
293
|
+
/** sessionId → the Session object for context measurement. */
|
|
294
|
+
private readonly sessions = new Map<string, Session>()
|
|
295
|
+
/** sessionId → context pressure cache (refreshed every N patches). */
|
|
296
|
+
private readonly contextCache = new Map<string, { contextTokens?: number; contextWindow?: number }>()
|
|
297
|
+
/** sessionId → serialized segment-send chain (keeps card order). */
|
|
298
|
+
private readonly segmentChains = new Map<string, Promise<void>>()
|
|
299
|
+
|
|
300
|
+
constructor(channel: FeishuChannel, refreshMs: number, hooks: StreamerHooks, heartbeatMs: number = 5_000) {
|
|
301
|
+
this.channel = channel
|
|
302
|
+
this.refreshMs = refreshMs
|
|
303
|
+
this.hooks = hooks
|
|
304
|
+
this.heartbeatMs = heartbeatMs
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Bind a freshly sent reply card to a session; seeds the stats fold from the log. */
|
|
308
|
+
attach(session: Session, cardMessageId: string, triggerMessageId: string, reactionId: string | undefined): void {
|
|
309
|
+
this.sessions.set(session.id, session)
|
|
310
|
+
let fold = this.folds.get(session.id)
|
|
311
|
+
if (fold === undefined) {
|
|
312
|
+
fold = foldAll(session.snapshotEvents())
|
|
313
|
+
this.folds.set(session.id, fold)
|
|
314
|
+
}
|
|
315
|
+
const queue = this.bySession.get(session.id) ?? []
|
|
316
|
+
queue.push({
|
|
317
|
+
turn: undefined,
|
|
318
|
+
triggerMessageId,
|
|
319
|
+
reactionId,
|
|
320
|
+
phase: 'thinking',
|
|
321
|
+
reasoning: '',
|
|
322
|
+
header: undefined,
|
|
323
|
+
lastUsage: undefined,
|
|
324
|
+
failure: undefined,
|
|
325
|
+
cards: [{
|
|
326
|
+
messageId: cardMessageId,
|
|
327
|
+
body: '',
|
|
328
|
+
dirty: true,
|
|
329
|
+
frozen: false,
|
|
330
|
+
mode: 'card',
|
|
331
|
+
failed: false,
|
|
332
|
+
sending: false,
|
|
333
|
+
failures: 0,
|
|
334
|
+
nextRetryAt: 0,
|
|
335
|
+
}],
|
|
336
|
+
dirty: true,
|
|
337
|
+
lastPatchAt: 0,
|
|
338
|
+
timer: undefined,
|
|
339
|
+
finalized: false,
|
|
340
|
+
disposed: false,
|
|
341
|
+
turnStartedAt: Date.now(),
|
|
342
|
+
toolActivity: undefined,
|
|
343
|
+
workflowRuns: new Map(),
|
|
344
|
+
workflowEndStatus: undefined,
|
|
345
|
+
children: new Map(),
|
|
346
|
+
settled: [],
|
|
347
|
+
status: '',
|
|
348
|
+
statusKind: '',
|
|
349
|
+
tool: '',
|
|
350
|
+
toolKind: '',
|
|
351
|
+
heartbeatTimer: undefined,
|
|
352
|
+
attempts: new Map(),
|
|
353
|
+
})
|
|
354
|
+
this.bySession.set(session.id, queue)
|
|
355
|
+
const state = queue[queue.length - 1]!
|
|
356
|
+
this.scheduleHeartbeat(session.id, state)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Drop all state for a session (agent disposed / route removed). */
|
|
360
|
+
detach(sessionId: string): void {
|
|
361
|
+
const queue = this.bySession.get(sessionId) ?? []
|
|
362
|
+
for (const state of queue) {
|
|
363
|
+
state.disposed = true
|
|
364
|
+
if (state.timer !== undefined) clearTimeout(state.timer)
|
|
365
|
+
if (state.heartbeatTimer !== undefined) clearTimeout(state.heartbeatTimer)
|
|
366
|
+
}
|
|
367
|
+
this.bySession.delete(sessionId)
|
|
368
|
+
this.folds.delete(sessionId)
|
|
369
|
+
this.sessions.delete(sessionId)
|
|
370
|
+
this.contextCache.delete(sessionId)
|
|
371
|
+
this.segmentChains.delete(sessionId)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
hasSession(sessionId: string): boolean {
|
|
375
|
+
return this.bySession.has(sessionId)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The front (oldest unfinished) turn state of a session. */
|
|
379
|
+
private front(sessionId: string): TurnState | undefined {
|
|
380
|
+
const queue = this.bySession.get(sessionId)
|
|
381
|
+
if (queue === undefined || queue.length === 0) return undefined
|
|
382
|
+
for (const state of queue) {
|
|
383
|
+
if (!state.finalized) return state
|
|
384
|
+
}
|
|
385
|
+
return undefined
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Consume one appended session event. */
|
|
389
|
+
onSessionEvent(session: Session, event: SessionEvent): void {
|
|
390
|
+
const fold = this.folds.get(session.id)
|
|
391
|
+
if (fold !== undefined) foldEvent(fold, event)
|
|
392
|
+
this.sessions.set(session.id, session)
|
|
393
|
+
|
|
394
|
+
// Subagent children live in their OWN sessions (header.parentSession →
|
|
395
|
+
// caller). Their events never touch the parent's log, so fold their
|
|
396
|
+
// status into the parent turn's activity line here.
|
|
397
|
+
const parentId = session.header.parentSession
|
|
398
|
+
if (parentId !== undefined) {
|
|
399
|
+
const parentState = this.front(parentId)
|
|
400
|
+
if (parentState !== undefined) {
|
|
401
|
+
this.foldChildEvent(parentId, parentState, session, event)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const state = this.front(session.id)
|
|
407
|
+
if (state === undefined) return
|
|
408
|
+
|
|
409
|
+
switch (event.type) {
|
|
410
|
+
case 'step/start': {
|
|
411
|
+
if (state.turn === undefined) {
|
|
412
|
+
state.turn = event.data.turn
|
|
413
|
+
this.markDirty(session.id, state)
|
|
414
|
+
}
|
|
415
|
+
break
|
|
416
|
+
}
|
|
417
|
+
case 'assistant/message': {
|
|
418
|
+
if (state.turn === undefined) state.turn = event.data.turn
|
|
419
|
+
if (event.data.usage !== undefined) {
|
|
420
|
+
state.lastUsage = {
|
|
421
|
+
usage: event.data.usage,
|
|
422
|
+
turn: event.data.turn,
|
|
423
|
+
step: event.data.step,
|
|
424
|
+
}
|
|
425
|
+
this.markDirty(session.id, state)
|
|
426
|
+
}
|
|
427
|
+
break
|
|
428
|
+
}
|
|
429
|
+
case 'request/header': {
|
|
430
|
+
const config = event.data.header.config
|
|
431
|
+
// Parse this exact event instead of copying the session fold's latest
|
|
432
|
+
// sample, so malformed/current events can never expose prior history.
|
|
433
|
+
state.header = {
|
|
434
|
+
provider: typeof config.provider === 'string' ? config.provider : '',
|
|
435
|
+
model: typeof config.model === 'string' ? config.model : '',
|
|
436
|
+
effort: typeof config.reasoningEffort === 'string' ? config.reasoningEffort : undefined,
|
|
437
|
+
}
|
|
438
|
+
this.markDirty(session.id, state)
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
case 'tool/call': {
|
|
442
|
+
if (state.turn === undefined) state.turn = event.data.turn
|
|
443
|
+
state.toolActivity = {
|
|
444
|
+
name: event.data.name,
|
|
445
|
+
label: toolActivityLabel(event.data.name, event.data.arguments),
|
|
446
|
+
since: Date.now(),
|
|
447
|
+
}
|
|
448
|
+
// The tool line is independent: it never supersedes the settled
|
|
449
|
+
// workflow end status on the delegation line (both render together).
|
|
450
|
+
this.markDirty(session.id, state)
|
|
451
|
+
break
|
|
452
|
+
}
|
|
453
|
+
case 'tool/result': {
|
|
454
|
+
if (state.turn === undefined) state.turn = event.data.turn
|
|
455
|
+
state.toolActivity = undefined
|
|
456
|
+
this.markDirty(session.id, state)
|
|
457
|
+
break
|
|
458
|
+
}
|
|
459
|
+
case 'tool-workflow/run-start': {
|
|
460
|
+
// Workflow record events carry no turn/step — they belong to the
|
|
461
|
+
// front (oldest unfinished) turn by construction.
|
|
462
|
+
state.workflowRuns.set(event.data.runId, {
|
|
463
|
+
name: event.data.name,
|
|
464
|
+
startedAt: Date.now(),
|
|
465
|
+
started: 0,
|
|
466
|
+
ended: 0,
|
|
467
|
+
active: [],
|
|
468
|
+
memberIds: new Set(),
|
|
469
|
+
})
|
|
470
|
+
state.workflowEndStatus = undefined
|
|
471
|
+
this.markDirty(session.id, state)
|
|
472
|
+
break
|
|
473
|
+
}
|
|
474
|
+
case 'tool-workflow/agent-start': {
|
|
475
|
+
const run = state.workflowRuns.get(event.data.runId)
|
|
476
|
+
if (run !== undefined) {
|
|
477
|
+
run.started++
|
|
478
|
+
run.active.push({ seq: event.data.seq, label: event.data.label })
|
|
479
|
+
run.memberIds.add(event.data.childId)
|
|
480
|
+
if (event.data.phase !== undefined) run.phase = event.data.phase
|
|
481
|
+
this.markDirty(session.id, state)
|
|
482
|
+
}
|
|
483
|
+
break
|
|
484
|
+
}
|
|
485
|
+
case 'tool-workflow/agent-end': {
|
|
486
|
+
const run = state.workflowRuns.get(event.data.runId)
|
|
487
|
+
if (run !== undefined) {
|
|
488
|
+
run.ended++
|
|
489
|
+
const index = run.active.findIndex(member => member.seq === event.data.seq)
|
|
490
|
+
if (index >= 0) run.active.splice(index, 1)
|
|
491
|
+
this.markDirty(session.id, state)
|
|
492
|
+
}
|
|
493
|
+
break
|
|
494
|
+
}
|
|
495
|
+
case 'tool-workflow/run-end': {
|
|
496
|
+
const run = state.workflowRuns.get(event.data.runId)
|
|
497
|
+
if (run !== undefined) {
|
|
498
|
+
state.workflowRuns.delete(event.data.runId)
|
|
499
|
+
state.workflowEndStatus = {
|
|
500
|
+
name: run.name,
|
|
501
|
+
stopReason: WORKFLOW_OUTCOMES[event.data.stopReason] ?? event.data.stopReason,
|
|
502
|
+
}
|
|
503
|
+
this.markDirty(session.id, state)
|
|
504
|
+
}
|
|
505
|
+
break
|
|
506
|
+
}
|
|
507
|
+
case 'turn/end': {
|
|
508
|
+
// The ended turn belongs to the front state: assign it if a turn was
|
|
509
|
+
// rejected/emptied before any step started.
|
|
510
|
+
if (state.turn === undefined) state.turn = event.data.turn
|
|
511
|
+
this.finalize(session.id, state, turnFailure(event.data.reason))
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
default:
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Consume one live assistant-stream frame (`agent/assistant-stream`).
|
|
521
|
+
*
|
|
522
|
+
* DSH stopped emitting durable `assistant/chunk` events (commit
|
|
523
|
+
* `f99b06eaed`), so this transient agent-scoped publication is the only
|
|
524
|
+
* live delta source. `start` records rollback marks; `end` keeps the deltas
|
|
525
|
+
* only when the attempt committed to the durable log — an `abandoned`
|
|
526
|
+
* attempt's text is not model-visible history and must leave the card again.
|
|
527
|
+
*/
|
|
528
|
+
onAssistantFrame(session: Session, frame: AssistantStreamFrame): void {
|
|
529
|
+
this.sessions.set(session.id, session)
|
|
530
|
+
const state = this.front(session.id)
|
|
531
|
+
if (state === undefined || state.disposed) return
|
|
532
|
+
switch (frame.type) {
|
|
533
|
+
case 'start': {
|
|
534
|
+
state.attempts.set(frame.attemptId, this.attemptMark(state))
|
|
535
|
+
if (state.turn === undefined) state.turn = frame.turn
|
|
536
|
+
break
|
|
537
|
+
}
|
|
538
|
+
case 'chunk': {
|
|
539
|
+
const chunk = frame.chunk
|
|
540
|
+
if (chunk.type === 'text-delta') {
|
|
541
|
+
const last = state.cards[state.cards.length - 1]
|
|
542
|
+
if (last !== undefined && !last.frozen && chunk.text !== '') {
|
|
543
|
+
last.body += chunk.text
|
|
544
|
+
if (state.phase === 'thinking') state.phase = 'replying'
|
|
545
|
+
this.markDirty(session.id, state)
|
|
546
|
+
}
|
|
547
|
+
} else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
|
|
548
|
+
state.reasoning += chunk.text
|
|
549
|
+
this.markDirty(session.id, state)
|
|
550
|
+
}
|
|
551
|
+
break
|
|
552
|
+
}
|
|
553
|
+
case 'end': {
|
|
554
|
+
const mark = state.attempts.get(frame.attemptId)
|
|
555
|
+
state.attempts.delete(frame.attemptId)
|
|
556
|
+
if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
|
|
557
|
+
this.rollbackAttempt(state, mark)
|
|
558
|
+
this.markDirty(session.id, state)
|
|
559
|
+
}
|
|
560
|
+
break
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Capture the card/body lengths an attempt's deltas start from. */
|
|
566
|
+
private attemptMark(state: TurnState): AttemptMark {
|
|
567
|
+
return {
|
|
568
|
+
cardCount: state.cards.length,
|
|
569
|
+
bodyLengths: state.cards.map(card => card.body.length),
|
|
570
|
+
reasoningLength: state.reasoning.length,
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Drop one abandoned attempt's deltas. The attempt's text is a contiguous
|
|
576
|
+
* suffix of the logical body: segments created during the attempt are
|
|
577
|
+
* removed, the segment that was last at `start` is truncated, and the
|
|
578
|
+
* reasoning tail is cut. Already-delivered plain-text segments cannot be
|
|
579
|
+
* retracted and stop the removal.
|
|
580
|
+
*/
|
|
581
|
+
private rollbackAttempt(state: TurnState, mark: AttemptMark): void {
|
|
582
|
+
const lastIndex = mark.cardCount - 1
|
|
583
|
+
for (let index = state.cards.length - 1; index > lastIndex; index -= 1) {
|
|
584
|
+
const card = state.cards[index]
|
|
585
|
+
if (card === undefined) continue
|
|
586
|
+
if (card.mode === 'text' && card.messageId !== undefined) break
|
|
587
|
+
state.cards.splice(index, 1)
|
|
588
|
+
}
|
|
589
|
+
const tail = state.cards[lastIndex]
|
|
590
|
+
const length = mark.bodyLengths[lastIndex]
|
|
591
|
+
if (tail !== undefined && length !== undefined
|
|
592
|
+
&& !(tail.mode === 'text' && tail.messageId !== undefined)) {
|
|
593
|
+
tail.body = tail.body.slice(0, length)
|
|
594
|
+
tail.dirty = true
|
|
595
|
+
}
|
|
596
|
+
state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private markDirty(sessionId: string, state: TurnState): void {
|
|
600
|
+
if (state.disposed) return
|
|
601
|
+
state.dirty = true
|
|
602
|
+
if (state.timer !== undefined) return
|
|
603
|
+
const wait = state.lastPatchAt === 0
|
|
604
|
+
? this.refreshMs
|
|
605
|
+
: Math.max(0, this.refreshMs - (Date.now() - state.lastPatchAt))
|
|
606
|
+
state.timer = setTimeout(() => {
|
|
607
|
+
state.timer = undefined
|
|
608
|
+
this.flush(sessionId, state)
|
|
609
|
+
}, wait)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private statsFor(sessionId: string, state: TurnState): CardState['stats'] {
|
|
613
|
+
const fold = this.folds.get(sessionId)
|
|
614
|
+
const lastUsage = state.lastUsage
|
|
615
|
+
const header = state.header
|
|
616
|
+
const cached = this.contextCache.get(sessionId)
|
|
617
|
+
const session = this.sessions.get(sessionId)
|
|
618
|
+
let context = cached
|
|
619
|
+
if (session !== undefined) {
|
|
620
|
+
try {
|
|
621
|
+
context = this.hooks.measureContext(session)
|
|
622
|
+
this.contextCache.set(sessionId, context)
|
|
623
|
+
} catch {
|
|
624
|
+
context = cached
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Host projections are authoritative when mounted: they fold the durable
|
|
628
|
+
// embedded assistant streams the plugin can no longer see as live events.
|
|
629
|
+
const projected = session === undefined ? undefined : this.hooks.projectStats?.(session)
|
|
630
|
+
return {
|
|
631
|
+
sessionId: this.hooks.sessionLabel(sessionId),
|
|
632
|
+
inputTokens: projected?.inputTokens ?? fold?.totals.inputTokens ?? 0,
|
|
633
|
+
outputTokens: projected?.outputTokens ?? fold?.totals.outputTokens ?? 0,
|
|
634
|
+
cacheHitPct: projected?.cacheHitPct ?? cacheHitPct(lastUsage),
|
|
635
|
+
contextTokens: context?.contextTokens,
|
|
636
|
+
// The context-token count is session-wide, but the model window belongs
|
|
637
|
+
// to the current request. Suppress it when this turn failed before a
|
|
638
|
+
// request header was emitted, just like model/effort/cache.
|
|
639
|
+
contextWindow: header === undefined
|
|
640
|
+
? undefined
|
|
641
|
+
: context?.contextWindow ?? projected?.contextWindow,
|
|
642
|
+
model: header?.model,
|
|
643
|
+
effort: header?.effort,
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Fold one event from a subagent child session into its parent turn's
|
|
649
|
+
* activity state. Only identity/progress signals are kept — the child's
|
|
650
|
+
* text output stays out of the user-facing card (the parent model receives
|
|
651
|
+
* it as the tool result and composes the final reply itself).
|
|
652
|
+
*
|
|
653
|
+
* Workflow members are skipped here: their childId is recorded on the run
|
|
654
|
+
* fold and the workflow line already aggregates them.
|
|
655
|
+
*/
|
|
656
|
+
private foldChildEvent(parentId: string, state: TurnState, child: Session, event: SessionEvent): void {
|
|
657
|
+
for (const run of state.workflowRuns.values()) {
|
|
658
|
+
if (run.memberIds.has(child.id)) return
|
|
659
|
+
}
|
|
660
|
+
switch (event.type) {
|
|
661
|
+
case 'subagent/descriptor': {
|
|
662
|
+
const label = typeof event.data.label === 'string' && event.data.label !== ''
|
|
663
|
+
? event.data.label
|
|
664
|
+
: '子代理'
|
|
665
|
+
const existing = state.children.get(child.id)
|
|
666
|
+
if (existing === undefined) {
|
|
667
|
+
state.children.set(child.id, { label, tool: undefined, since: Date.now() })
|
|
668
|
+
} else if (existing.label === '子代理') {
|
|
669
|
+
existing.label = label
|
|
670
|
+
}
|
|
671
|
+
this.markDirty(parentId, state)
|
|
672
|
+
break
|
|
673
|
+
}
|
|
674
|
+
case 'tool/call': {
|
|
675
|
+
const existing = state.children.get(child.id)
|
|
676
|
+
if (existing === undefined) {
|
|
677
|
+
state.children.set(child.id, { label: '子代理', tool: event.data.name, since: Date.now() })
|
|
678
|
+
} else {
|
|
679
|
+
existing.tool = event.data.name
|
|
680
|
+
}
|
|
681
|
+
this.markDirty(parentId, state)
|
|
682
|
+
break
|
|
683
|
+
}
|
|
684
|
+
case 'tool/result': {
|
|
685
|
+
const existing = state.children.get(child.id)
|
|
686
|
+
if (existing !== undefined && existing.tool !== undefined) {
|
|
687
|
+
existing.tool = undefined
|
|
688
|
+
this.markDirty(parentId, state)
|
|
689
|
+
}
|
|
690
|
+
break
|
|
691
|
+
}
|
|
692
|
+
case 'turn/end': {
|
|
693
|
+
const entry = state.children.get(child.id)
|
|
694
|
+
if (entry === undefined) break
|
|
695
|
+
state.children.delete(child.id)
|
|
696
|
+
// A finished child leaves the live fold but is summarized on the
|
|
697
|
+
// settled card (capped; placeholder labels are skipped).
|
|
698
|
+
if (entry.label !== '子代理' && state.settled.length < 3) {
|
|
699
|
+
state.settled.push(entry.label)
|
|
700
|
+
}
|
|
701
|
+
this.markDirty(parentId, state)
|
|
702
|
+
break
|
|
703
|
+
}
|
|
704
|
+
default:
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The delegation status line, by priority:
|
|
711
|
+
* 1. the most recently started live workflow run (member progress + phase);
|
|
712
|
+
* 2. running subagent children (label + current tool + elapsed);
|
|
713
|
+
* 3. the last settled workflow run's outcome — it persists across later
|
|
714
|
+
* tool calls and text streaming (only a new run-start replaces it), so
|
|
715
|
+
* the workflow conclusion stays visible;
|
|
716
|
+
* 4. heartbeat fallback — a thinking turn with no output yet still shows
|
|
717
|
+
* an elapsed counter so the card never sits silently.
|
|
718
|
+
* Once the turn is finalized, the line switches to the SETTLED form
|
|
719
|
+
* (workflow conclusion + finished-subagent summary) — the final card keeps
|
|
720
|
+
* the delegation status; only the tool line disappears.
|
|
721
|
+
*/
|
|
722
|
+
private delegationLine(state: TurnState, now: number): string {
|
|
723
|
+
if (state.failure !== undefined) {
|
|
724
|
+
const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
|
|
725
|
+
return clipActivity(`🔴 本轮运行失败${suffix}`)
|
|
726
|
+
}
|
|
727
|
+
if (state.finalized) return this.settledLine(state)
|
|
728
|
+
|
|
729
|
+
let latest: WorkflowRun | undefined
|
|
730
|
+
for (const run of state.workflowRuns.values()) {
|
|
731
|
+
if (latest === undefined || run.startedAt > latest.startedAt) latest = run
|
|
732
|
+
}
|
|
733
|
+
if (latest !== undefined) {
|
|
734
|
+
let line = `🧵 工作流 ${latest.name} · ${latest.ended}/${latest.started} 子代理完成`
|
|
735
|
+
if (latest.phase !== undefined) line += ` · 阶段 ${latest.phase}`
|
|
736
|
+
const labels = latest.active.slice(0, 3).map(member => member.label)
|
|
737
|
+
if (labels.length > 0) line += ` · ${labels.join('、')}`
|
|
738
|
+
line += ` · ${fmtElapsed(now - latest.startedAt)}`
|
|
739
|
+
return clipActivity(line)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const children = [...state.children.values()]
|
|
743
|
+
if (children.length > 0) {
|
|
744
|
+
const listed = children.slice(0, 2).map(child => {
|
|
745
|
+
const tool = child.tool === undefined ? '' : ` (${child.tool})`
|
|
746
|
+
return `${child.label}${tool}`
|
|
747
|
+
})
|
|
748
|
+
const head = children.length === 1
|
|
749
|
+
? `🤖 子代理执行中:${listed[0]}`
|
|
750
|
+
: `🤖 ${children.length} 个子代理执行中:${listed.join('、')}${children.length > 2 ? '、…' : ''}`
|
|
751
|
+
const oldest = Math.min(...children.map(child => child.since))
|
|
752
|
+
return clipActivity(`${head} · ${fmtElapsed(now - oldest)}`)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (state.workflowEndStatus !== undefined) {
|
|
756
|
+
const { name, stopReason } = state.workflowEndStatus
|
|
757
|
+
return clipActivity(`🧵 工作流 ${name} 结束(${stopReason})`)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const empty = state.cards.every(card => card.body === '')
|
|
761
|
+
// The tool line already shows liveness while a call is in flight, so the
|
|
762
|
+
// heartbeat fallback stays out of the way.
|
|
763
|
+
if (state.phase === 'thinking' && empty && state.toolActivity === undefined) {
|
|
764
|
+
return clipActivity(`⏳ 处理中 · ${fmtElapsed(now - state.turnStartedAt)}`)
|
|
765
|
+
}
|
|
766
|
+
return ''
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The settled-card form of the delegation status line: the workflow
|
|
771
|
+
* conclusion and/or the finished-subagent summary, without elapsed counters.
|
|
772
|
+
* Still-running (background) children are noted factually. Empty when the
|
|
773
|
+
* turn delegated nothing.
|
|
774
|
+
*/
|
|
775
|
+
private settledLine(state: TurnState): string {
|
|
776
|
+
const parts: string[] = []
|
|
777
|
+
if (state.workflowEndStatus !== undefined) {
|
|
778
|
+
const { name, stopReason } = state.workflowEndStatus
|
|
779
|
+
parts.push(`🧵 工作流 ${name} 结束(${stopReason})`)
|
|
780
|
+
}
|
|
781
|
+
if (state.settled.length > 0) {
|
|
782
|
+
parts.push(`🤖 子代理完成:${state.settled.join('、')}`)
|
|
783
|
+
}
|
|
784
|
+
if (parts.length > 0) return clipActivity(parts.join(' · '))
|
|
785
|
+
|
|
786
|
+
const children = [...state.children.values()]
|
|
787
|
+
if (children.length > 0) {
|
|
788
|
+
const listed = children.slice(0, 2).map(child =>
|
|
789
|
+
`${child.label}${child.tool === undefined ? '' : ` (${child.tool})`}`)
|
|
790
|
+
const head = children.length === 1
|
|
791
|
+
? `🤖 子代理执行中:${listed[0]}`
|
|
792
|
+
: `🤖 ${children.length} 个子代理执行中:${listed.join('、')}${children.length > 2 ? '、…' : ''}`
|
|
793
|
+
return clipActivity(head)
|
|
794
|
+
}
|
|
795
|
+
return ''
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The tool-call line: the parent agent's in-flight tool call, independent
|
|
800
|
+
* of the delegation status line — workflow/subagent status never hides it
|
|
801
|
+
* and it never hides them. Cleared by the tool's `tool/result`; always
|
|
802
|
+
* empty on the settled card.
|
|
803
|
+
*/
|
|
804
|
+
private toolLine(state: TurnState, now: number): string {
|
|
805
|
+
if (state.finalized) return ''
|
|
806
|
+
const tool = state.toolActivity
|
|
807
|
+
if (tool === undefined) return ''
|
|
808
|
+
const prefix = tool.name === 'subagent'
|
|
809
|
+
? '🤖 调用子代理:'
|
|
810
|
+
: tool.name === 'workflow'
|
|
811
|
+
? '🧵 调用工作流:'
|
|
812
|
+
: '🔧 调用工具:'
|
|
813
|
+
return clipActivity(`${prefix}${tool.label} · ${fmtElapsed(now - tool.since)}`)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Slow keep-alive: recompute both activity lines and patch only when their
|
|
818
|
+
* rendered text actually changed (the elapsed counter ticking). Reschedules
|
|
819
|
+
* itself while the turn is pending; `finalize`/`detach` cancel it.
|
|
820
|
+
*/
|
|
821
|
+
private scheduleHeartbeat(sessionId: string, state: TurnState): void {
|
|
822
|
+
if (state.disposed || state.finalized || state.heartbeatTimer !== undefined) return
|
|
823
|
+
state.heartbeatTimer = setTimeout(() => {
|
|
824
|
+
state.heartbeatTimer = undefined
|
|
825
|
+
if (state.disposed || state.finalized) return
|
|
826
|
+
const now = Date.now()
|
|
827
|
+
if (this.delegationLine(state, now) !== state.status
|
|
828
|
+
|| this.toolLine(state, now) !== state.tool) {
|
|
829
|
+
this.markDirty(sessionId, state)
|
|
830
|
+
}
|
|
831
|
+
this.scheduleHeartbeat(sessionId, state)
|
|
832
|
+
}, this.heartbeatMs)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private cardState(sessionId: string, state: TurnState, body: string): CardState {
|
|
836
|
+
// The snapshot point recomputes (and remembers) both activity lines so
|
|
837
|
+
// every patch — including heartbeat-driven ones — carries the current
|
|
838
|
+
// status and tool call.
|
|
839
|
+
const now = Date.now()
|
|
840
|
+
const status = this.delegationLine(state, now)
|
|
841
|
+
if (status !== state.status) {
|
|
842
|
+
state.status = status
|
|
843
|
+
const kind = this.noteKind('status', status, state.statusKind)
|
|
844
|
+
if (kind !== undefined) state.statusKind = kind
|
|
845
|
+
}
|
|
846
|
+
const tool = this.toolLine(state, now)
|
|
847
|
+
if (tool !== state.tool) {
|
|
848
|
+
state.tool = tool
|
|
849
|
+
const kind = this.noteKind('tool', tool, state.toolKind)
|
|
850
|
+
if (kind !== undefined) state.toolKind = kind
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
phase: state.phase,
|
|
854
|
+
reasoning: state.reasoning,
|
|
855
|
+
body,
|
|
856
|
+
status: state.status,
|
|
857
|
+
tool: state.tool,
|
|
858
|
+
stats: this.statsFor(sessionId, state),
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Diagnostic: log activity-type transitions only (first segment of the
|
|
864
|
+
* line, stable across elapsed/count ticks) so a live test can confirm
|
|
865
|
+
* exactly which status the card is being patched with.
|
|
866
|
+
*/
|
|
867
|
+
private noteKind(slot: 'status' | 'tool', line: string, current: string): string | undefined {
|
|
868
|
+
const kind = line === '' ? '' : (line.split('·')[0] ?? '').trim()
|
|
869
|
+
if (kind === current) return undefined
|
|
870
|
+
console.info(`[dsh-channels] activity ${slot} →`, kind === '' ? '(收起)' : kind)
|
|
871
|
+
return kind
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Freeze the newest card once its body stops fitting its budget (bytes or
|
|
876
|
+
* the per-card table cap) and move the overflow into fresh segment cards
|
|
877
|
+
* (their sends are chained in order).
|
|
878
|
+
*/
|
|
879
|
+
private splitOverflow(sessionId: string, state: TurnState): void {
|
|
880
|
+
for (;;) {
|
|
881
|
+
const index = state.cards.length - 1
|
|
882
|
+
const card = state.cards[index]
|
|
883
|
+
if (card === undefined || card.frozen || card.mode === 'text') return
|
|
884
|
+
|
|
885
|
+
const options = index === 0 ? {} : { panel: false, segment: index + 1 }
|
|
886
|
+
const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
|
|
887
|
+
if (split.head === '') {
|
|
888
|
+
// Degenerate budget (cannot happen with the real fixed overhead):
|
|
889
|
+
// keep streaming into this card; buildCard's absolute clip still
|
|
890
|
+
// guards the card size.
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
if (split.tail === '') {
|
|
894
|
+
card.body = split.head
|
|
895
|
+
card.dirty = true
|
|
896
|
+
// A card with content but no message id yet (e.g. the continuation of
|
|
897
|
+
// a text-fallback segment) needs its own send, not just a patch.
|
|
898
|
+
if (card.messageId === undefined && !card.sending && !card.failed && card.body !== '') {
|
|
899
|
+
this.sendSegment(sessionId, state, index)
|
|
900
|
+
}
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
card.body = split.head
|
|
905
|
+
card.frozen = true
|
|
906
|
+
card.dirty = true
|
|
907
|
+
const next: SegmentCard = this.freshSegment(split.tail)
|
|
908
|
+
state.cards.push(next)
|
|
909
|
+
this.sendSegment(sessionId, state, state.cards.length - 1)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private freshSegment(body: string): SegmentCard {
|
|
914
|
+
return {
|
|
915
|
+
messageId: undefined,
|
|
916
|
+
body,
|
|
917
|
+
dirty: true,
|
|
918
|
+
frozen: false,
|
|
919
|
+
mode: undefined,
|
|
920
|
+
failed: false,
|
|
921
|
+
sending: false,
|
|
922
|
+
failures: 0,
|
|
923
|
+
nextRetryAt: 0,
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private delay(ms: number): Promise<void> {
|
|
928
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Serialize one continuation-card send per session (order on Feishu), with
|
|
933
|
+
* up to two retries (1s / 2s backoff) for transient failures. When delivery
|
|
934
|
+
* resolves as a text fallback the segment is frozen and further growth
|
|
935
|
+
* continues in a new segment — text messages cannot be patched afterwards.
|
|
936
|
+
*/
|
|
937
|
+
private sendSegment(sessionId: string, state: TurnState, index: number): void {
|
|
938
|
+
const card = state.cards[index]
|
|
939
|
+
if (card === undefined || card.failed || card.sending || state.disposed) return
|
|
940
|
+
card.sending = true
|
|
941
|
+
|
|
942
|
+
const previous = this.segmentChains.get(sessionId) ?? Promise.resolve()
|
|
943
|
+
const next = previous.then(async () => {
|
|
944
|
+
let lastError: unknown
|
|
945
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
946
|
+
try {
|
|
947
|
+
const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
|
|
948
|
+
panel: false,
|
|
949
|
+
segment: index + 1,
|
|
950
|
+
})
|
|
951
|
+
const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
|
|
952
|
+
card.sending = false
|
|
953
|
+
card.messageId = delivery.messageId
|
|
954
|
+
card.mode = delivery.mode
|
|
955
|
+
this.hooks.onSegmentDelivered?.(
|
|
956
|
+
state.cards[0]?.messageId ?? '',
|
|
957
|
+
index,
|
|
958
|
+
delivery.messageId,
|
|
959
|
+
delivery.mode,
|
|
960
|
+
)
|
|
961
|
+
if (delivery.mode === 'card') {
|
|
962
|
+
card.dirty = true
|
|
963
|
+
this.markDirty(sessionId, state)
|
|
964
|
+
} else if (!card.frozen && !state.disposed) {
|
|
965
|
+
// Text fallback: this card is final; keep streaming into a new one.
|
|
966
|
+
card.frozen = true
|
|
967
|
+
if (state.cards[state.cards.length - 1] === card) {
|
|
968
|
+
state.cards.push(this.freshSegment(''))
|
|
969
|
+
}
|
|
970
|
+
this.markDirty(sessionId, state)
|
|
971
|
+
}
|
|
972
|
+
return
|
|
973
|
+
} catch (error) {
|
|
974
|
+
lastError = error
|
|
975
|
+
if (attempt >= 2 || state.disposed) break
|
|
976
|
+
await this.delay(1_000 * 2 ** attempt)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
card.sending = false
|
|
980
|
+
card.failed = true
|
|
981
|
+
this.hooks.onError(sessionId, lastError)
|
|
982
|
+
})
|
|
983
|
+
this.segmentChains.set(sessionId, next.then(() => {}, () => {}))
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Patch one card when due; failed updates retry with exponential backoff
|
|
987
|
+
* (logged on the first failure and every 10th — no per-flush spam).
|
|
988
|
+
*
|
|
989
|
+
* The patch gate covers both per-card body growth (`card.dirty`) and
|
|
990
|
+
* turn-scoped snapshot changes (`turnDirty`: phase, reasoning, activity
|
|
991
|
+
* line, session stats), so activity-only events still reach the card. */
|
|
992
|
+
private patchCard(sessionId: string, state: TurnState, index: number, force: boolean, turnDirty: boolean): void {
|
|
993
|
+
const card = state.cards[index]
|
|
994
|
+
if (card === undefined || card.messageId === undefined || card.mode === 'text' || card.failed) return
|
|
995
|
+
const now = Date.now()
|
|
996
|
+
if (!force && !card.dirty && !turnDirty) return
|
|
997
|
+
if (!force && card.nextRetryAt > now) return
|
|
998
|
+
card.dirty = false
|
|
999
|
+
const options = index === 0 ? {} : { panel: false, segment: index + 1 }
|
|
1000
|
+
const snapshot = buildCard(this.cardState(sessionId, state, card.body), options)
|
|
1001
|
+
this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
|
|
1002
|
+
card.dirty = true
|
|
1003
|
+
card.failures++
|
|
1004
|
+
card.nextRetryAt = Date.now() + Math.min(1_000 * 2 ** Math.min(card.failures, 6), 60_000)
|
|
1005
|
+
if (card.failures === 1 || card.failures % 10 === 0) {
|
|
1006
|
+
this.hooks.onError(sessionId, error)
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private flush(sessionId: string, state: TurnState): void {
|
|
1012
|
+
state.lastPatchAt = Date.now()
|
|
1013
|
+
const turnDirty = state.dirty
|
|
1014
|
+
if (state.dirty) {
|
|
1015
|
+
state.dirty = false
|
|
1016
|
+
this.splitOverflow(sessionId, state)
|
|
1017
|
+
}
|
|
1018
|
+
for (const [index] of state.cards.entries()) {
|
|
1019
|
+
this.patchCard(sessionId, state, index, false, turnDirty)
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private finalize(
|
|
1024
|
+
sessionId: string,
|
|
1025
|
+
state: TurnState,
|
|
1026
|
+
failure: { message: string; code?: string } | undefined,
|
|
1027
|
+
): void {
|
|
1028
|
+
if (state.finalized) return
|
|
1029
|
+
state.finalized = true
|
|
1030
|
+
state.failure = failure
|
|
1031
|
+
state.phase = failure === undefined ? 'done' : 'error'
|
|
1032
|
+
if (failure !== undefined) {
|
|
1033
|
+
const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
|
|
1034
|
+
const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
|
|
1035
|
+
const tail = state.cards[state.cards.length - 1]
|
|
1036
|
+
if (tail !== undefined) {
|
|
1037
|
+
if (tail.mode === 'text' || tail.failed) {
|
|
1038
|
+
// An already-delivered plain-text fallback cannot be patched, and a
|
|
1039
|
+
// permanently failed card cannot surface new content. Put the
|
|
1040
|
+
// terminal diagnostic in its own continuation card instead.
|
|
1041
|
+
state.cards.push(this.freshSegment(notice))
|
|
1042
|
+
} else {
|
|
1043
|
+
tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n---\n\n${notice}`
|
|
1044
|
+
tail.dirty = true
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (state.timer !== undefined) {
|
|
1049
|
+
clearTimeout(state.timer)
|
|
1050
|
+
state.timer = undefined
|
|
1051
|
+
}
|
|
1052
|
+
// The settled card keeps the delegation status (workflow conclusion /
|
|
1053
|
+
// subagent summary — `delegationLine` renders its settled form once
|
|
1054
|
+
// `finalized` is set); only the transient tool line disappears. The
|
|
1055
|
+
// heartbeat stops.
|
|
1056
|
+
if (state.heartbeatTimer !== undefined) {
|
|
1057
|
+
clearTimeout(state.heartbeatTimer)
|
|
1058
|
+
state.heartbeatTimer = undefined
|
|
1059
|
+
}
|
|
1060
|
+
state.tool = ''
|
|
1061
|
+
// Freeze the tail and spawn any remaining segment cards.
|
|
1062
|
+
this.splitOverflow(sessionId, state)
|
|
1063
|
+
|
|
1064
|
+
// Final patches with the settled content: every patchable card flips to
|
|
1065
|
+
// 回复结束 / 回复失败 (backoff is bypassed once so the final state always lands;
|
|
1066
|
+
// late-arriving sends are patched when they resolve).
|
|
1067
|
+
for (const [index] of state.cards.entries()) {
|
|
1068
|
+
this.patchCard(sessionId, state, index, true, false)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Reaction swap: Typing → DONE on success, Typing → No on failure.
|
|
1072
|
+
void this.swapReaction(state, failure !== undefined)
|
|
1073
|
+
|
|
1074
|
+
// Prune the finalized state lazily (kept until the next attach so the
|
|
1075
|
+
// front-scan skips it, then removed).
|
|
1076
|
+
void Promise.resolve().then(() => {
|
|
1077
|
+
const queue = this.bySession.get(sessionId)
|
|
1078
|
+
if (queue === undefined) return
|
|
1079
|
+
const index = queue.indexOf(state)
|
|
1080
|
+
if (index >= 0) queue.splice(index, 1)
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private async swapReaction(state: TurnState, failed: boolean): Promise<void> {
|
|
1087
|
+
try {
|
|
1088
|
+
if (state.reactionId !== undefined) {
|
|
1089
|
+
await this.channel.removeReaction(state.triggerMessageId, state.reactionId)
|
|
1090
|
+
}
|
|
1091
|
+
await this.channel.addReaction(state.triggerMessageId, failed ? 'No' : 'DONE')
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
// Reaction failures are cosmetic: never surface them to the user.
|
|
1094
|
+
this.hooks.onError(state.triggerMessageId, error)
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Extract the user-visible failure from the current Harness turn-end union. */
|
|
1100
|
+
function turnFailure(reason: TurnEndReason): { message: string; code?: string } | undefined {
|
|
1101
|
+
if (typeof reason !== 'object' || reason === null || reason.kind !== 'error') return undefined
|
|
1102
|
+
const error = reason.error
|
|
1103
|
+
const message = typeof error?.message === 'string' && error.message !== ''
|
|
1104
|
+
? error.message
|
|
1105
|
+
: '模型请求失败'
|
|
1106
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : undefined
|
|
1107
|
+
return { message, code }
|
|
1108
|
+
}
|