@modesto_ma/dsh-channels 0.2.0 → 0.3.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/src/streamer.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  * lines: the delegation status line (子代理 / 工作流 progress, settled
12
12
  * workflow outcome, heartbeat) and the tool line (the parent's in-flight
13
13
  * tool call). The lines are INDEPENDENT — a later tool call never
14
- * supersedes the settled workflow status. Subagent children live in their
14
+ * supersedes the settled workflow status. The lines render only on the
15
+ * card that is currently streaming, so frozen segment cards never repeat
16
+ * a stale status. Subagent children live in their
15
17
  * own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
16
18
  * events are folded into the parent turn's status line through
17
19
  * `session.header.parentSession` (status only — the child's text output
@@ -19,10 +21,11 @@
19
21
  * 3. When the body outgrows one card's budget, the overflow is split at a
20
22
  * clean breakpoint and sent as a continuation segment card (segment 2, 3,
21
23
  * …) 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.
24
+ * card. Reasoning is never segmented — it stays on card 1 inside the
25
+ * fold panel's own fixed window, independent of the body length.
26
+ * 4. `finalize(turn)` — flip every segment card to its terminal state
27
+ * (`回复结束` / `回复失败`), render the settled footer on the main card and
28
+ * request the Typing → DONE/No reaction swap on the triggering message.
26
29
  *
27
30
  * The first visible text delta flips the panel title 推理中 → 回复中; the
28
31
  * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
@@ -38,7 +41,7 @@
38
41
  */
39
42
 
40
43
  import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
41
- import { buildCard, segmentCardContent } from './cards.ts'
44
+ import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
42
45
  import type { CardState, PanelPhase } from './cards.ts'
43
46
  import type { FeishuChannel } from './feishu-channel.ts'
44
47
  import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
@@ -49,7 +52,7 @@ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
49
52
  * console can confirm which build of the activity status line is loaded.
50
53
  * Bump on every behavior change to this feature.
51
54
  */
52
- export const ACTIVITY_BUILD_MARKER = 'activity-status-v6'
55
+ export const ACTIVITY_BUILD_MARKER = 'activity-status-v7'
53
56
 
54
57
  /** How a segment card reached Feishu. */
55
58
  export interface SegmentDelivery {
@@ -149,8 +152,6 @@ interface TurnState {
149
152
  header: HeaderSample | undefined
150
153
  /** Provider usage produced during THIS turn; never inherited from history. */
151
154
  lastUsage: UsageSample | undefined
152
- /** Structured terminal failure for this turn, when `turn/end.kind=error`. */
153
- failure: { message: string; code?: string } | undefined
154
155
  /** Segment cards in order; `cards[0]` is the attached reply card. */
155
156
  cards: SegmentCard[]
156
157
  dirty: boolean
@@ -161,6 +162,12 @@ interface TurnState {
161
162
  disposed: boolean
162
163
  /** Unix ms when the turn was attached (heartbeat elapsed base). */
163
164
  turnStartedAt: number
165
+ /** Wall-clock start of the currently open reasoning phase (undefined = not reasoning). */
166
+ reasoningSince: number | undefined
167
+ /** Accumulated wall-clock time the turn spent reasoning, in ms. A phase
168
+ * opens at the first reasoning delta and closes when the step's output ends
169
+ * (first answer text or step end), so tool execution is never counted. */
170
+ reasoningMs: number
164
171
  /** The in-flight tool call, if any (cleared by its `tool/result`). */
165
172
  toolActivity: { name: string; label: string; since: number } | undefined
166
173
  /** Live workflow-run folds projected by the workflow tool into this session. */
@@ -202,6 +209,8 @@ interface AttemptMark {
202
209
  cardCount: number
203
210
  bodyLengths: number[]
204
211
  reasoningLength: number
212
+ reasoningMs: number
213
+ reasoningSince: number | undefined
205
214
  }
206
215
 
207
216
  /** One live `tool-workflow/run-*` fold: run identity and member progress. */
@@ -321,7 +330,6 @@ export class TurnStreamer {
321
330
  reasoning: '',
322
331
  header: undefined,
323
332
  lastUsage: undefined,
324
- failure: undefined,
325
333
  cards: [{
326
334
  messageId: cardMessageId,
327
335
  body: '',
@@ -339,6 +347,8 @@ export class TurnStreamer {
339
347
  finalized: false,
340
348
  disposed: false,
341
349
  turnStartedAt: Date.now(),
350
+ reasoningSince: undefined,
351
+ reasoningMs: 0,
342
352
  toolActivity: undefined,
343
353
  workflowRuns: new Map(),
344
354
  workflowEndStatus: undefined,
@@ -531,6 +541,9 @@ export class TurnStreamer {
531
541
  if (state === undefined || state.disposed) return
532
542
  switch (frame.type) {
533
543
  case 'start': {
544
+ // A new step starts: close any reasoning phase the previous step left
545
+ // open (defensive — its `end` frame normally does this first).
546
+ this.closeReasoning(state)
534
547
  state.attempts.set(frame.attemptId, this.attemptMark(state))
535
548
  if (state.turn === undefined) state.turn = frame.turn
536
549
  break
@@ -538,6 +551,7 @@ export class TurnStreamer {
538
551
  case 'chunk': {
539
552
  const chunk = frame.chunk
540
553
  if (chunk.type === 'text-delta') {
554
+ if (chunk.text !== '') this.closeReasoning(state)
541
555
  const last = state.cards[state.cards.length - 1]
542
556
  if (last !== undefined && !last.frozen && chunk.text !== '') {
543
557
  last.body += chunk.text
@@ -545,12 +559,17 @@ export class TurnStreamer {
545
559
  this.markDirty(session.id, state)
546
560
  }
547
561
  } else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
562
+ if (state.reasoningSince === undefined) state.reasoningSince = Date.now()
548
563
  state.reasoning += chunk.text
549
564
  this.markDirty(session.id, state)
550
565
  }
551
566
  break
552
567
  }
553
568
  case 'end': {
569
+ // The step's output is complete: a reasoning phase that ended with a
570
+ // tool call (no answer text) stops counting here, so the tool run that
571
+ // follows is not miscounted as reasoning time.
572
+ this.closeReasoning(state)
554
573
  const mark = state.attempts.get(frame.attemptId)
555
574
  state.attempts.delete(frame.attemptId)
556
575
  if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
@@ -568,9 +587,18 @@ export class TurnStreamer {
568
587
  cardCount: state.cards.length,
569
588
  bodyLengths: state.cards.map(card => card.body.length),
570
589
  reasoningLength: state.reasoning.length,
590
+ reasoningMs: state.reasoningMs,
591
+ reasoningSince: state.reasoningSince,
571
592
  }
572
593
  }
573
594
 
595
+ /** Close an open reasoning phase, accumulating its wall-clock time. */
596
+ private closeReasoning(state: TurnState, now = Date.now()): void {
597
+ if (state.reasoningSince === undefined) return
598
+ state.reasoningMs += Math.max(0, now - state.reasoningSince)
599
+ state.reasoningSince = undefined
600
+ }
601
+
574
602
  /**
575
603
  * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
576
604
  * suffix of the logical body: segments created during the attempt are
@@ -594,6 +622,9 @@ export class TurnStreamer {
594
622
  tail.dirty = true
595
623
  }
596
624
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
625
+ // The abandoned attempt's reasoning time is not part of the visible turn.
626
+ state.reasoningMs = mark.reasoningMs
627
+ state.reasoningSince = mark.reasoningSince
597
628
  }
598
629
 
599
630
  private markDirty(sessionId: string, state: TurnState): void {
@@ -641,6 +672,9 @@ export class TurnStreamer {
641
672
  : context?.contextWindow ?? projected?.contextWindow,
642
673
  model: header?.model,
643
674
  effort: header?.effort,
675
+ // Only a turn that actually reasoned reports a duration; a non-reasoning
676
+ // model leaves the field undefined (rendered as `—`).
677
+ reasoningMs: state.reasoning === '' ? undefined : state.reasoningMs,
644
678
  }
645
679
  }
646
680
 
@@ -717,13 +751,10 @@ export class TurnStreamer {
717
751
  * an elapsed counter so the card never sits silently.
718
752
  * Once the turn is finalized, the line switches to the SETTLED form
719
753
  * (workflow conclusion + finished-subagent summary) — the final card keeps
720
- * the delegation status; only the tool line disappears.
754
+ * the delegation status; only the tool line disappears. A failed turn shows
755
+ * its diagnostic once, in the body: the status line never repeats it.
721
756
  */
722
757
  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
758
  if (state.finalized) return this.settledLine(state)
728
759
 
729
760
  let latest: WorkflowRun | undefined
@@ -832,10 +863,14 @@ export class TurnStreamer {
832
863
  }, this.heartbeatMs)
833
864
  }
834
865
 
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.
866
+ /**
867
+ * Snapshot one card. `active` is true for the card currently streaming:
868
+ * only that card renders the transient activity lines, so frozen segment
869
+ * cards never repeat a stale tool/delegation line. Both lines are still
870
+ * recomputed (and remembered) on every snapshot so heartbeat/transition
871
+ * logging stays accurate.
872
+ */
873
+ private cardState(sessionId: string, state: TurnState, body: string, active = true): CardState {
839
874
  const now = Date.now()
840
875
  const status = this.delegationLine(state, now)
841
876
  if (status !== state.status) {
@@ -853,8 +888,8 @@ export class TurnStreamer {
853
888
  phase: state.phase,
854
889
  reasoning: state.reasoning,
855
890
  body,
856
- status: state.status,
857
- tool: state.tool,
891
+ status: active ? state.status : '',
892
+ tool: active ? state.tool : '',
858
893
  stats: this.statsFor(sessionId, state),
859
894
  }
860
895
  }
@@ -882,7 +917,7 @@ export class TurnStreamer {
882
917
  const card = state.cards[index]
883
918
  if (card === undefined || card.frozen || card.mode === 'text') return
884
919
 
885
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
920
+ const options = segmentCardOptions(index)
886
921
  const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
887
922
  if (split.head === '') {
888
923
  // Degenerate budget (cannot happen with the real fixed overhead):
@@ -944,10 +979,10 @@ export class TurnStreamer {
944
979
  let lastError: unknown
945
980
  for (let attempt = 0; attempt < 3; attempt++) {
946
981
  try {
947
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
948
- panel: false,
949
- segment: index + 1,
950
- })
982
+ const snapshot = buildCard(
983
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
984
+ segmentCardOptions(index),
985
+ )
951
986
  const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
952
987
  card.sending = false
953
988
  card.messageId = delivery.messageId
@@ -996,8 +1031,11 @@ export class TurnStreamer {
996
1031
  if (!force && !card.dirty && !turnDirty) return
997
1032
  if (!force && card.nextRetryAt > now) return
998
1033
  card.dirty = false
999
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
1000
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), options)
1034
+ const options = segmentCardOptions(index)
1035
+ const snapshot = buildCard(
1036
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
1037
+ options,
1038
+ )
1001
1039
  this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
1002
1040
  card.dirty = true
1003
1041
  card.failures++
@@ -1027,8 +1065,10 @@ export class TurnStreamer {
1027
1065
  ): void {
1028
1066
  if (state.finalized) return
1029
1067
  state.finalized = true
1030
- state.failure = failure
1031
1068
  state.phase = failure === undefined ? 'done' : 'error'
1069
+ // A turn that ends inside a reasoning phase (e.g. no answer text) still
1070
+ // reports the time it spent reasoning.
1071
+ this.closeReasoning(state)
1032
1072
  if (failure !== undefined) {
1033
1073
  const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
1034
1074
  const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
@@ -1040,7 +1080,7 @@ export class TurnStreamer {
1040
1080
  // terminal diagnostic in its own continuation card instead.
1041
1081
  state.cards.push(this.freshSegment(notice))
1042
1082
  } else {
1043
- tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n---\n\n${notice}`
1083
+ tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n${notice}`
1044
1084
  tail.dirty = true
1045
1085
  }
1046
1086
  }
package/src/workspaces.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  /**
2
- * Workspace directory resolution: one DSH workspace per (user × chat).
2
+ * Workspace directory resolution: one DSH workspace per user, one working
3
+ * directory per Feishu topic.
3
4
  *
4
- * Every Feishu topic gets its own workspace, grouped by the (chat, user) pair:
5
- * - p2p: `<baseDir>/p2p/<open_id>/<topic>/`
6
- * - group: `<baseDir>/groups/<chat_id>/<open_id>/<topic>/`
5
+ * - p2p: workspace `<baseDir>/p2p/<open_id>/`
6
+ * - group: workspace `<baseDir>/groups/<chat_id>/<open_id>/`
7
+ * - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
8
+ *
9
+ * The workspace root is the Session header cwd: it keeps the Session grouped
10
+ * under the user's project in the GUI and stays the sandbox write boundary.
11
+ * The per-topic directory is where the tools actually work (see
12
+ * `./session/isolation.ts`), so files produced by different topics never mix.
13
+ * Older versions registered one workspace per topic under `<baseDir>/topics/`;
14
+ * those directories are kept verbatim for legacy routes and are only removed
15
+ * by the retention cleanup, never migrated.
7
16
  *
8
17
  * Attachments received before a topic exists are staged below
9
18
  * `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
10
- * topic workspace once its first Session is created.
19
+ * topic directory once its first Session is created.
11
20
  *
12
21
  * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
13
22
  * anyway so a hostile id can never escape `baseDir`.
@@ -123,36 +132,51 @@ export function topicTokenFor(
123
132
  return kind === 'thread' ? safeSegment(routeKey, 'route key') : safeSegment(sessionId, 'session id')
124
133
  }
125
134
 
135
+ /** Git branch (and worktree directory) name for one topic token. */
136
+ export function worktreeBranchFor(topicToken: string): string {
137
+ return `feishu-${safeSegment(topicToken, 'topic token')}`
138
+ }
139
+
126
140
  /**
127
- * Deterministic workspace directory for one Feishu topic: every route gets its
128
- * own workspace below its (user × chat) grouping directory.
141
+ * Deterministic workspace directory for one user in one chat context: the
142
+ * durable project the user's Sessions are grouped under.
129
143
  *
130
- * - p2p: `<baseDir>/topics/p2p/<open_id>/<topic>/`
131
- * - group: `<baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/`
144
+ * - p2p: `<baseDir>/p2p/<open_id>/`
145
+ * - group: `<baseDir>/groups/<chat_id>/<open_id>/`
132
146
  *
133
- * A separate `topics/` root keeps new workspaces out of the legacy (user ×
134
- * chat) workspace directories left by older versions, so cleaning one can
135
- * never delete the other.
147
+ * These are the same paths older versions used as their shared (user × chat)
148
+ * workspaces, so an upgraded deployment reuses the existing registry record
149
+ * instead of creating a second project for the same user.
136
150
  */
137
151
  export function workspacePathFor(
138
152
  baseDir: string,
139
153
  chatType: 'p2p' | 'group',
140
154
  chatId: string,
141
155
  senderOpenId: string,
142
- topicToken: string,
143
156
  ): string {
144
- const chat = safeSegment(chatId, 'chatId')
145
157
  const user = safeSegment(senderOpenId, 'senderOpenId')
146
- const topic = safeSegment(topicToken, 'topic token')
147
- return chatType === 'p2p'
148
- ? path.join(baseDir, 'topics', 'p2p', user, topic)
149
- : path.join(baseDir, 'topics', 'groups', chat, user, topic)
158
+ if (chatType === 'p2p') return path.join(baseDir, 'p2p', user)
159
+ return path.join(baseDir, 'groups', safeSegment(chatId, 'chatId'), user)
160
+ }
161
+
162
+ /** Per-topic working directory inside one user workspace. */
163
+ export function topicDirFor(workspacePath: string, topicToken: string): string {
164
+ return path.join(workspacePath, 'topics', safeSegment(topicToken, 'topic token'))
165
+ }
166
+
167
+ /**
168
+ * Root of the legacy (one workspace per topic) directories. Legacy workspaces
169
+ * live strictly below this path, which is how the retention cleanup tells them
170
+ * apart from user workspaces that must never be deleted.
171
+ */
172
+ export function legacyTopicWorkspaceRoot(baseDir: string): string {
173
+ return path.join(baseDir, 'topics')
150
174
  }
151
175
 
152
176
  /**
153
177
  * Staging directory for attachments that arrive before their topic (and so
154
- * its workspace) exists. Not a registered workspace: files move into the real
155
- * workspace as soon as the first Session of that topic is created.
178
+ * its topic directory) exists. Not a registered workspace: files move into the
179
+ * real topic directory as soon as the first Session of that topic is created.
156
180
  */
157
181
  export function stagingPathFor(
158
182
  baseDir: string,
@@ -170,17 +194,16 @@ export function workspaceTitleFor(
170
194
  chatType: 'p2p' | 'group',
171
195
  chatId: string,
172
196
  senderOpenId: string,
173
- topicToken?: string,
174
197
  ): string {
175
- const base = chatType === 'p2p'
198
+ return chatType === 'p2p'
176
199
  ? `飞书私聊 · ${senderOpenId}`
177
200
  : `飞书群聊 · ${chatId} · ${senderOpenId}`
178
- return topicToken === undefined ? base : `${base} · ${topicToken}`
179
201
  }
180
202
 
181
203
  /**
182
- * Ensure one topic's workspace directory exists and is registered with the
183
- * host's workspace registry. Idempotent: repeated calls return the same record.
204
+ * Ensure one user's workspace directory exists and is registered with the
205
+ * host's workspace registry. Idempotent: repeated calls return the same record,
206
+ * and an existing record keeps its (possibly renamed) title.
184
207
  */
185
208
  export async function ensureWorkspace(
186
209
  ctx: HostContext,
@@ -188,12 +211,11 @@ export async function ensureWorkspace(
188
211
  chatType: 'p2p' | 'group',
189
212
  chatId: string,
190
213
  senderOpenId: string,
191
- topicToken: string,
192
214
  ): Promise<Workspace> {
193
- const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId, topicToken)
215
+ const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId)
194
216
  await mkdir(dir, { recursive: true })
195
217
  return ctx.workspaceRegistry.create(
196
218
  dir,
197
- workspaceTitleFor(chatType, chatId, senderOpenId, topicToken),
219
+ workspaceTitleFor(chatType, chatId, senderOpenId),
198
220
  )
199
221
  }