@addozhang/dsh-discord 0.5.0-alpha.1 → 0.5.0-alpha.2
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/dsh/host-asks.d.ts +6 -0
- package/lib/dsh/host-asks.js +10 -2
- package/lib/dsh/host-events.js +48 -3
- package/lib/i18n.d.ts +5 -0
- package/lib/i18n.js +14 -0
- package/lib/index.js +14 -0
- package/lib/stream/live.d.ts +14 -0
- package/lib/stream/live.js +147 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
20
20
|
|
|
21
21
|
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (your message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
|
|
22
22
|
- **Stream rendering** — typing indicator, one head message edited in place, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
23
|
-
- **Approvals & questions** — DSH
|
|
23
|
+
- **Approvals & questions** — DSH asks become buttons, select menus, and a free-text modal. Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
|
|
24
24
|
- **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
|
|
25
25
|
- **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
|
|
26
26
|
- **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
|
|
@@ -28,7 +28,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
28
28
|
|
|
29
29
|
## Requirements
|
|
30
30
|
|
|
31
|
-
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.
|
|
31
|
+
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` or a compatible newer release, running a web profile. Adapter `0.5.x` uses the 0.1.6 controller services and is not compatible with the former `apiProxy` host surface.
|
|
32
32
|
- Node.js `^22.19.0 || >=24`
|
|
33
33
|
- A Discord application with a bot user and the **MESSAGE CONTENT** privileged intent enabled (Developer Portal → your application → Bot → Privileged Gateway Intents)
|
|
34
34
|
|
|
@@ -119,7 +119,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
119
119
|
- The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
|
|
120
120
|
- A credential probe falls back to `resolve()` because the Host's `describe()` misses env-sourced values — a connected adapter never reads as unconfigured.
|
|
121
121
|
- Adapter logging is default-quiet: flow records ride the Host's debug level and failure-shaped events escalate to warn — nothing prints into the DSH process at the default level.
|
|
122
|
-
- Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the
|
|
122
|
+
- Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the Host wires no plugin log exporter and exposes no log-level switch at this surface — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
|
|
123
123
|
|
|
124
124
|
## Known Limitations and Deferred Work
|
|
125
125
|
|
package/README.zh.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
22
22
|
- **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
23
|
-
- **审批与提问** — DSH ask
|
|
23
|
+
- **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
24
|
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
25
|
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
26
|
- **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
## 环境要求
|
|
30
30
|
|
|
31
|
-
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.
|
|
31
|
+
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` 或兼容的更新版本(web profile)。适配器 `0.5.x` 使用 0.1.6 的控制器服务,与旧版 `apiProxy` 宿主面不兼容。
|
|
32
32
|
- Node.js `^22.19.0 || >=24`
|
|
33
33
|
- 一个 Discord 应用(含 Bot 用户),并在开发者门户启用 **MESSAGE CONTENT** 特权 intent(Developer Portal → 你的应用 → Bot → Privileged Gateway Intents)
|
|
34
34
|
|
|
@@ -120,7 +120,7 @@ dsh-discord:
|
|
|
120
120
|
- 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
|
|
121
121
|
- 凭据探测会回退到 `resolve()`:Host 的 `describe()` 不识别环境变量来源的值——已连接的适配器不会被误报为未配置。
|
|
122
122
|
- 适配器日志默认静默:流程记录走 Host 的 debug 级别,失败形态的事件升到 warn——默认级别下不会向 DSH 进程打印任何内容。
|
|
123
|
-
- 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:
|
|
123
|
+
- 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:Host 在该面上未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
|
|
124
124
|
|
|
125
125
|
## 已知限制与推迟项
|
|
126
126
|
|
package/lib/dsh/host-asks.d.ts
CHANGED
|
@@ -73,6 +73,12 @@ export interface HostAskDeps {
|
|
|
73
73
|
questionTimeoutMs(): number;
|
|
74
74
|
nowMs(): number;
|
|
75
75
|
log(event: string, detail?: unknown): void;
|
|
76
|
+
/**
|
|
77
|
+
* Claim/settle steering for the live progress line (turn-progress spec):
|
|
78
|
+
* called with `true` when a thread-bound ask is claimed, `false` when it
|
|
79
|
+
* settles (answered, cancelled, or expired).
|
|
80
|
+
*/
|
|
81
|
+
onWaitState?(threadId: string, waiting: boolean): void;
|
|
76
82
|
}
|
|
77
83
|
/**
|
|
78
84
|
* Wrap both ask services. Thread-bound sessions get the Discord ask flow;
|
package/lib/dsh/host-asks.js
CHANGED
|
@@ -91,6 +91,7 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
91
91
|
}
|
|
92
92
|
const approvalId = crypto.randomUUID();
|
|
93
93
|
deps.log('discord_approval_claimed', { approvalId, sessionId, threadId, toolName: req.toolName });
|
|
94
|
+
deps.onWaitState?.(threadId, true);
|
|
94
95
|
deps.askWiring.onApprovalRequested({
|
|
95
96
|
sessionId,
|
|
96
97
|
threadId,
|
|
@@ -106,7 +107,10 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
106
107
|
pendingApprovals.set(approvalId, pending);
|
|
107
108
|
if (req.signal?.aborted === true)
|
|
108
109
|
pending.dispose();
|
|
109
|
-
void pending.promise.then(() => {
|
|
110
|
+
void pending.promise.then(() => {
|
|
111
|
+
deps.onWaitState?.(threadId, false);
|
|
112
|
+
void deps.askWiring.disableControl(approvalId);
|
|
113
|
+
});
|
|
110
114
|
return pending.promise;
|
|
111
115
|
};
|
|
112
116
|
if (questions !== undefined && originalAsk !== undefined) {
|
|
@@ -123,6 +127,7 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
123
127
|
return originalAsk(req);
|
|
124
128
|
const rpcId = crypto.randomUUID();
|
|
125
129
|
deps.log('discord_question_claimed', { rpcId, sessionId, threadId, questions: rows.length });
|
|
130
|
+
deps.onWaitState?.(threadId, true);
|
|
126
131
|
deps.askWiring.onQuestionRequested({
|
|
127
132
|
sessionId,
|
|
128
133
|
threadId,
|
|
@@ -135,7 +140,10 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
135
140
|
pendingQuestions.set(rpcId, pending);
|
|
136
141
|
if (req.signal?.aborted === true)
|
|
137
142
|
pending.dispose();
|
|
138
|
-
void pending.promise.then(() => {
|
|
143
|
+
void pending.promise.then(() => {
|
|
144
|
+
deps.onWaitState?.(threadId, false);
|
|
145
|
+
void deps.askWiring.disableControl(rpcId);
|
|
146
|
+
});
|
|
139
147
|
return pending.promise;
|
|
140
148
|
};
|
|
141
149
|
}
|
package/lib/dsh/host-events.js
CHANGED
|
@@ -105,9 +105,19 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
105
105
|
const per = new AbortController();
|
|
106
106
|
tracked.set(sessionId, per);
|
|
107
107
|
void (async () => {
|
|
108
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1')
|
|
109
|
+
console.error(`[dsh-discord:trace] follow-start session=${sessionId.slice(0, 8)} attempt=${String(attempt)}`);
|
|
108
110
|
try {
|
|
109
111
|
consumer?.push({ type: 'session/subscribed', sessionId });
|
|
110
|
-
|
|
112
|
+
// The follow request MUST ask for assistant streaming: without the
|
|
113
|
+
// flag the host (alpha.2) delivers only the opening snapshot and
|
|
114
|
+
// never pushes later journal records — the live tail stays dead
|
|
115
|
+
// (diagnosis.md §D, run 2 vs run 3).
|
|
116
|
+
for await (const raw of services.follow({ address: { kind: 'session', sessionId }, assistantStream: true }, per.signal)) {
|
|
117
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
118
|
+
const dumped = JSON.stringify(raw);
|
|
119
|
+
console.error(`[dsh-discord:trace] raw-follow-frame type=${String(isRecord(raw) ? raw['type'] : typeof raw)} frame=${dumped.length > 300 ? `${dumped.slice(0, 300)}…` : dumped}`);
|
|
120
|
+
}
|
|
111
121
|
if (!isRecord(raw))
|
|
112
122
|
continue;
|
|
113
123
|
const frame = raw;
|
|
@@ -115,12 +125,23 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
115
125
|
// same {type, data, seq} record envelope the rc.2 mux delivered;
|
|
116
126
|
// the seq watermark keeps replayed windows idempotent.
|
|
117
127
|
if (frame['type'] === 'event' || frame['type'] === 'snapshot') {
|
|
128
|
+
// Two carriers, both real-machine verified (alpha.2): snapshot
|
|
129
|
+
// windows batch their records under `records`, while live records
|
|
130
|
+
// arrive as single-record frames — the record rides the `event`
|
|
131
|
+
// key of the frame itself, no array. Snapshots without a records
|
|
132
|
+
// array stay drops (malformed), live frames become their own
|
|
133
|
+
// one-element batch.
|
|
118
134
|
const records = raw.records;
|
|
119
|
-
|
|
135
|
+
const batch = Array.isArray(records)
|
|
136
|
+
? records
|
|
137
|
+
: frame['type'] === 'event'
|
|
138
|
+
? [raw]
|
|
139
|
+
: undefined;
|
|
140
|
+
if (batch === undefined)
|
|
120
141
|
continue;
|
|
121
142
|
const through = watermark.get(sessionId) ?? 0;
|
|
122
143
|
let delivered = through;
|
|
123
|
-
for (const record of
|
|
144
|
+
for (const record of batch) {
|
|
124
145
|
// Journal records arrive double-wrapped: {type:'event',
|
|
125
146
|
// event:{type, seq, time, data}} — the wire event rides the
|
|
126
147
|
// `event` key. Accept the flat shape defensively too.
|
|
@@ -138,6 +159,12 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
138
159
|
if (inner.seq > delivered)
|
|
139
160
|
delivered = inner.seq;
|
|
140
161
|
}
|
|
162
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
163
|
+
// Full data dump (truncated): diagnosis needs the field
|
|
164
|
+
// shapes, not just the key names — keys hide nesting.
|
|
165
|
+
const dumped = JSON.stringify(inner.data);
|
|
166
|
+
console.error(`[dsh-discord:trace] record type=${inner.type} seq=${String(inner.seq)} data=${dumped.length > 800 ? `${dumped.slice(0, 800)}…` : dumped}`);
|
|
167
|
+
}
|
|
141
168
|
consumer?.push({
|
|
142
169
|
type: 'session/event',
|
|
143
170
|
sessionId,
|
|
@@ -150,16 +177,28 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
150
177
|
else if (frame['type'] === 'assistant-stream') {
|
|
151
178
|
// Live assistant deltas ride the dedicated stream frames; the
|
|
152
179
|
// renderer's durable events already carry the message texts.
|
|
180
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
181
|
+
const dumped = JSON.stringify(frame);
|
|
182
|
+
console.error(`[dsh-discord:trace] assistant-stream frame=${dumped.length > 300 ? `${dumped.slice(0, 300)}…` : dumped}`);
|
|
183
|
+
}
|
|
153
184
|
continue;
|
|
154
185
|
}
|
|
186
|
+
else if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
187
|
+
const dumped = JSON.stringify(frame);
|
|
188
|
+
console.error(`[dsh-discord:trace] follow-frame UNHANDLED type=${String(frame['type'])} frame=${dumped.length > 400 ? `${dumped.slice(0, 400)}…` : dumped}`);
|
|
189
|
+
}
|
|
155
190
|
}
|
|
156
191
|
}
|
|
157
192
|
catch (cause) {
|
|
158
193
|
if (!per.signal.aborted) {
|
|
194
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1')
|
|
195
|
+
console.error(`[dsh-discord:trace] follow-threw session=${sessionId.slice(0, 8)} cause=${String(cause).slice(0, 300)}`);
|
|
159
196
|
log?.('discord_host_follow_threw', { sessionId, cause: String(cause) });
|
|
160
197
|
}
|
|
161
198
|
}
|
|
162
199
|
finally {
|
|
200
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1')
|
|
201
|
+
console.error(`[dsh-discord:trace] follow-end session=${sessionId.slice(0, 8)} aborted=${String(per.signal.aborted)} tracked=${String(tracked.get(sessionId) === per)}`);
|
|
163
202
|
if (tracked.get(sessionId) === per)
|
|
164
203
|
tracked.delete(sessionId);
|
|
165
204
|
// A follow stream may END normally once its snapshot is delivered
|
|
@@ -185,6 +224,10 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
185
224
|
if (!isRecord(raw))
|
|
186
225
|
continue;
|
|
187
226
|
const frame = raw;
|
|
227
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
228
|
+
const dumped = JSON.stringify(frame);
|
|
229
|
+
console.error(`[dsh-discord:trace] control type=${String(frame['type'])} frame=${dumped.length > 400 ? `${dumped.slice(0, 400)}…` : dumped}`);
|
|
230
|
+
}
|
|
188
231
|
if (frame['type'] === 'queue' && typeof frame['sessionId'] === 'string') {
|
|
189
232
|
const rawItems = Array.isArray(frame['items']) ? frame['items'] : [];
|
|
190
233
|
const items = rawItems
|
|
@@ -218,6 +261,8 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
218
261
|
startLoop(sessionId);
|
|
219
262
|
},
|
|
220
263
|
stream(signal) {
|
|
264
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1')
|
|
265
|
+
console.error(`[dsh-discord:trace] router-stream-open tracked=${String(tracked.size)} rearm=${String(tracked.size)}`);
|
|
221
266
|
rootSignal = signal;
|
|
222
267
|
const queue = createFrameQueue();
|
|
223
268
|
consumer = queue;
|
package/lib/i18n.d.ts
CHANGED
|
@@ -85,6 +85,11 @@ declare const zh: {
|
|
|
85
85
|
unboundNoticeAdministrator: string;
|
|
86
86
|
unboundNoticeMember: string;
|
|
87
87
|
interruptedMarker: string;
|
|
88
|
+
progressThinking: string;
|
|
89
|
+
progressThinkingStep: (step: number) => string;
|
|
90
|
+
progressWriting: string;
|
|
91
|
+
progressApprovalWait: string;
|
|
92
|
+
progressTurnSummary: (total: number, failed: number, breakdown: string) => string;
|
|
88
93
|
modelNeedsThread: string;
|
|
89
94
|
modelShowUnavailable: string;
|
|
90
95
|
modelShowHeader: (sel: string, groups: number) => string;
|
package/lib/i18n.js
CHANGED
|
@@ -93,6 +93,13 @@ const zh = {
|
|
|
93
93
|
unboundNoticeMember: '💡 此频道未绑定工作区;请工作区管理员运行 `/project bind`。',
|
|
94
94
|
// ── stream renderer ──────────────────────────────────────────────────
|
|
95
95
|
interruptedMarker: '*(已被中断)*',
|
|
96
|
+
progressThinking: '⏳ 思考中…',
|
|
97
|
+
progressThinkingStep: (step) => `⏳ 思考中…(步骤 ${String(step)})`,
|
|
98
|
+
progressWriting: '✍️ 撰写回复…',
|
|
99
|
+
progressApprovalWait: '⏳ 等待审批…',
|
|
100
|
+
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
101
|
+
? `⚙️ 本轮 ${String(total)} 次工具调用 · ${String(failed)} 失败 ✗(${breakdown})`
|
|
102
|
+
: `⚙️ 本轮 ${String(total)} 次工具调用 ✓(${breakdown})`,
|
|
96
103
|
// ── /model show / select ─────────────────────────────────────────────
|
|
97
104
|
modelNeedsThread: '⚠️ /model 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
|
|
98
105
|
modelShowUnavailable: '⚠️ 模型目录暂时不可用,请稍后重试。',
|
|
@@ -215,6 +222,13 @@ const en = {
|
|
|
215
222
|
unboundNoticeAdministrator: '💡 This channel is not bound to a workspace. A workspace administrator can run `/project bind` to create and bind the project channel.',
|
|
216
223
|
unboundNoticeMember: '💡 This channel is not bound to a workspace; ask a workspace administrator to run `/project bind`.',
|
|
217
224
|
interruptedMarker: '*(interrupted)*',
|
|
225
|
+
progressThinking: '⏳ Thinking…',
|
|
226
|
+
progressThinkingStep: (step) => `⏳ Thinking… (step ${String(step)})`,
|
|
227
|
+
progressWriting: '✍️ Writing the answer…',
|
|
228
|
+
progressApprovalWait: '⏳ Waiting for approval…',
|
|
229
|
+
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
230
|
+
? `⚙️ ${String(total)} tool calls this turn · ${String(failed)} failed ✗ (${breakdown})`
|
|
231
|
+
: `⚙️ ${String(total)} tool calls this turn ✓ (${breakdown})`,
|
|
218
232
|
// ── /model show / select ─────────────────────────────────────────────
|
|
219
233
|
modelNeedsThread: '⚠️ /model needs a thread bound to a Session (mention the bot in a project channel first).',
|
|
220
234
|
modelShowUnavailable: '⚠️ The model catalog is temporarily unavailable; try again later.',
|
package/lib/index.js
CHANGED
|
@@ -711,6 +711,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
711
711
|
if (approvalService === undefined)
|
|
712
712
|
throw new TypeError('dsh-discord cannot reach the approval service');
|
|
713
713
|
const hostAsksRef = { current: undefined };
|
|
714
|
+
// Ask-claim steering for the turn progress line (turn-progress spec):
|
|
715
|
+
// declared before the patches so the closure is stable; the live
|
|
716
|
+
// renderer attaches later in startup (asks cannot fire before then).
|
|
717
|
+
const liveProgressRef = { current: undefined };
|
|
714
718
|
hostAsksRef.current = installAskServicePatches(approvalService, questionsService, {
|
|
715
719
|
threadForSession,
|
|
716
720
|
askWiring,
|
|
@@ -718,6 +722,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
718
722
|
questionTimeoutMs: () => current.questionTimeoutMs,
|
|
719
723
|
nowMs: () => Date.now(),
|
|
720
724
|
log: rpcLog,
|
|
725
|
+
onWaitState: (threadId, waiting) => { liveProgressRef.current?.setApprovalWait(threadId, waiting); },
|
|
721
726
|
});
|
|
722
727
|
ctx.effect(() => () => { hostAsksRef.current?.dispose(); }, 'dsh-discord host ask answerers');
|
|
723
728
|
const approvalRespondPort = {
|
|
@@ -1081,6 +1086,13 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1081
1086
|
verbosity: current.defaultVerbosity,
|
|
1082
1087
|
log: rpcLog,
|
|
1083
1088
|
interruptedMarker: () => copy.interruptedMarker,
|
|
1089
|
+
progressCopy: () => ({
|
|
1090
|
+
thinking: copy.progressThinking,
|
|
1091
|
+
thinkingStep: copy.progressThinkingStep,
|
|
1092
|
+
writing: copy.progressWriting,
|
|
1093
|
+
approvalWait: copy.progressApprovalWait,
|
|
1094
|
+
turnSummary: copy.progressTurnSummary,
|
|
1095
|
+
}),
|
|
1084
1096
|
onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
|
|
1085
1097
|
onTurnEnded: (sessionId) => {
|
|
1086
1098
|
const turn = turnTracker.active(sessionId);
|
|
@@ -1089,6 +1101,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1089
1101
|
rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
|
|
1090
1102
|
},
|
|
1091
1103
|
});
|
|
1104
|
+
// The ask patches steer the same renderer's progress line (3.2 wiring).
|
|
1105
|
+
liveProgressRef.current = liveRef.current;
|
|
1092
1106
|
ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
|
|
1093
1107
|
ctx.effect(() => () => { runtimeRef.current?.dispose(); }, 'dsh-discord composed runtime');
|
|
1094
1108
|
})().catch((cause) => {
|
package/lib/stream/live.d.ts
CHANGED
|
@@ -95,9 +95,23 @@ export interface LiveRenderDeps {
|
|
|
95
95
|
}>) => void;
|
|
96
96
|
/** Localized interruption suffix, resolved live (language can change). */
|
|
97
97
|
interruptedMarker?: () => string;
|
|
98
|
+
/**
|
|
99
|
+
* Localized progress-phase copy, resolved live (language can change).
|
|
100
|
+
* Unset: the status line is omitted and only tool rows render (the
|
|
101
|
+
* pre-progress behavior).
|
|
102
|
+
*/
|
|
103
|
+
progressCopy?: () => {
|
|
104
|
+
thinking: string;
|
|
105
|
+
thinkingStep: (step: number) => string;
|
|
106
|
+
writing: string;
|
|
107
|
+
approvalWait: string;
|
|
108
|
+
turnSummary: (total: number, failed: number, breakdown: string) => string;
|
|
109
|
+
};
|
|
98
110
|
/** Turn ownership release on turn/end. */
|
|
99
111
|
onTurnEnded?: (sessionId: string) => void;
|
|
100
112
|
}
|
|
101
113
|
export declare function startLiveRender(deps: LiveRenderDeps): {
|
|
102
114
|
dispose(): void;
|
|
115
|
+
/** Approval-wait steering for the ask patches (turn-progress spec). */
|
|
116
|
+
setApprovalWait(threadId: string, waiting: boolean): void;
|
|
103
117
|
};
|
package/lib/stream/live.js
CHANGED
|
@@ -61,6 +61,17 @@ function resultCallId(data) {
|
|
|
61
61
|
const block = Array.isArray(message?.content) ? message.content[0] : undefined;
|
|
62
62
|
return typeof block?.toolCallId === 'string' ? block.toolCallId : undefined;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The failure flag of a tool/result event. The rc.2 wire carried a top-level
|
|
66
|
+
* `error`; the 0.1.6 journal marks failure on the result block (`isError`).
|
|
67
|
+
*/
|
|
68
|
+
function resultFailed(data) {
|
|
69
|
+
if (data['error'] !== undefined)
|
|
70
|
+
return true;
|
|
71
|
+
const message = data['message'];
|
|
72
|
+
const block = Array.isArray(message?.content) ? message.content[0] : undefined;
|
|
73
|
+
return block?.isError === true;
|
|
74
|
+
}
|
|
64
75
|
/**
|
|
65
76
|
* The Host presentation view's title for one tool event: a terminal call's
|
|
66
77
|
* title IS the command; generic/diff cards title the call. Host-curated
|
|
@@ -97,6 +108,9 @@ export function startLiveRender(deps) {
|
|
|
97
108
|
finalizer: undefined,
|
|
98
109
|
headMessageId: undefined,
|
|
99
110
|
activityMessageId: undefined,
|
|
111
|
+
activityFlush: undefined,
|
|
112
|
+
progressPhase: undefined,
|
|
113
|
+
phaseBeforeApproval: undefined,
|
|
100
114
|
headFlush: undefined,
|
|
101
115
|
stepSeq: 0,
|
|
102
116
|
headAttempted: false,
|
|
@@ -146,6 +160,7 @@ export function startLiveRender(deps) {
|
|
|
146
160
|
runtime.turnId = turnId;
|
|
147
161
|
runtime.headMessageId = undefined;
|
|
148
162
|
runtime.activityMessageId = undefined;
|
|
163
|
+
runtime.activityFlush = undefined;
|
|
149
164
|
runtime.headFlush = undefined;
|
|
150
165
|
runtime.headAttempted = false;
|
|
151
166
|
runtime.activityAttempted = false;
|
|
@@ -168,6 +183,10 @@ export function startLiveRender(deps) {
|
|
|
168
183
|
onFlushError: (cause) => { deps.log?.('discord_live_activity_flush_failed', { threadId, cause: String(cause) }); },
|
|
169
184
|
});
|
|
170
185
|
runtime.finalizer = undefined;
|
|
186
|
+
// The status line opens the turn in the thinking phase (spec
|
|
187
|
+
// turn-progress: the message exists from turn/start, before any tool).
|
|
188
|
+
runtime.progressPhase = { kind: 'thinking', step: 1 };
|
|
189
|
+
runtime.phaseBeforeApproval = undefined;
|
|
171
190
|
// A fresh lifecycle per turn: start() no-ops on a stopped one, so a
|
|
172
191
|
// second turn in the same thread would otherwise never type again.
|
|
173
192
|
runtime.typing.dispose();
|
|
@@ -177,40 +196,84 @@ export function startLiveRender(deps) {
|
|
|
177
196
|
onFailure: (cause) => { deps.log?.('discord_live_typing_threw', { threadId, cause: String(cause) }); },
|
|
178
197
|
});
|
|
179
198
|
runtime.typing.start();
|
|
199
|
+
runtime.activityScheduler.schedule(renderActivityContent(runtime));
|
|
180
200
|
}
|
|
181
|
-
/** The
|
|
201
|
+
/** The status line's phase entry (undefined when copy is not provided). */
|
|
202
|
+
function progressPhaseLine(runtime) {
|
|
203
|
+
const copy = deps.progressCopy?.();
|
|
204
|
+
if (copy === undefined || runtime.progressPhase === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
const phase = runtime.progressPhase;
|
|
207
|
+
switch (phase.kind) {
|
|
208
|
+
case 'thinking': return phase.step > 1 ? copy.thinkingStep(phase.step) : copy.thinking;
|
|
209
|
+
case 'tool': return `💻 ${truncateText(phase.title ?? phase.label, ACTIVITY_TITLE_MAX)}`;
|
|
210
|
+
case 'writing': return copy.writing;
|
|
211
|
+
case 'approval': return copy.approvalWait;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The turn's collapsed one-line summary (decision 5): the durable trace a
|
|
216
|
+
* finished turn leaves behind. Undefined when the turn ran no tools —
|
|
217
|
+
* those keep the pure Q&A thread and the message is deleted instead.
|
|
218
|
+
*/
|
|
219
|
+
function renderTurnSummary(rows) {
|
|
220
|
+
const copy = deps.progressCopy?.();
|
|
221
|
+
if (copy === undefined || rows.length === 0)
|
|
222
|
+
return undefined;
|
|
223
|
+
const counts = new Map();
|
|
224
|
+
for (const row of rows)
|
|
225
|
+
counts.set(row.label, (counts.get(row.label) ?? 0) + 1);
|
|
226
|
+
const breakdown = truncateText([...counts.entries()].map(([label, n]) => `${label} ×${String(n)}`).join(' · '), 200);
|
|
227
|
+
const failed = rows.filter(row => row.state === 'failed').length;
|
|
228
|
+
return copy.turnSummary(rows.length, failed, breakdown);
|
|
229
|
+
}
|
|
230
|
+
/** The activity message body: phase line, then one icon + title per call row. */
|
|
182
231
|
function renderActivityContent(runtime) {
|
|
183
232
|
const rows = runtime.tools.render();
|
|
184
|
-
|
|
233
|
+
const rowLines = rows.map(row => {
|
|
185
234
|
const title = truncateText(row.title ?? row.label, ACTIVITY_TITLE_MAX);
|
|
186
|
-
|
|
187
|
-
|
|
235
|
+
const mark = row.state === 'succeeded' ? '✓ ' : row.state === 'failed' ? '✗ ' : '';
|
|
236
|
+
return `> ${mark}${toolCategoryIcon(row.label)} ${title}`;
|
|
237
|
+
});
|
|
238
|
+
const phaseLine = progressPhaseLine(runtime);
|
|
239
|
+
return phaseLine === undefined ? rowLines.join('\n') : [phaseLine, ...rowLines].join('\n');
|
|
188
240
|
}
|
|
189
241
|
/** Render the tool rows into one bounded activity message (create once, edit after). */
|
|
190
242
|
function renderActivity(threadId, runtime) {
|
|
191
243
|
return async () => {
|
|
192
|
-
if (runtime.tools.render().length === 0)
|
|
244
|
+
if (runtime.tools.render().length === 0 && progressPhaseLine(runtime) === undefined)
|
|
193
245
|
return;
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
246
|
+
// The flush promise records the message id it created so turn/end's
|
|
247
|
+
// cleanup can delete a send that is still in flight (orphan guard).
|
|
248
|
+
const flush = (async () => {
|
|
249
|
+
// Tool titles are Host-presented free text (terminal commands): they
|
|
250
|
+
// go through the same outbound builder as every other message path.
|
|
251
|
+
const payload = buildOutboundMessage({ kind: 'tool', content: renderActivityContent(runtime) });
|
|
252
|
+
if (runtime.activityMessageId === undefined) {
|
|
253
|
+
if (runtime.activityAttempted)
|
|
254
|
+
return undefined;
|
|
255
|
+
const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
|
|
256
|
+
if (sent.outcome === 'completed') {
|
|
257
|
+
runtime.activityMessageId = sent.messageId;
|
|
258
|
+
return sent.messageId;
|
|
259
|
+
}
|
|
260
|
+
if (sent.outcome === 'unknown') {
|
|
261
|
+
runtime.activityAttempted = true;
|
|
262
|
+
deps.log?.('discord_live_activity_send_unknown', { threadId });
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
206
265
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
266
|
+
await deps.delivery.edit({ channelId: threadId, messageId: runtime.activityMessageId, content: payload.content });
|
|
267
|
+
return undefined;
|
|
268
|
+
})();
|
|
269
|
+
runtime.activityFlush = flush;
|
|
270
|
+
await flush;
|
|
210
271
|
};
|
|
211
272
|
}
|
|
212
273
|
function handleSessionEvent(sessionId, threadId, runtime, event, frameView) {
|
|
213
274
|
const data = event.data;
|
|
275
|
+
if (TRACE)
|
|
276
|
+
trace('handleSessionEvent', event.type, 'keys:', Object.keys(data).join(','));
|
|
214
277
|
const turnId = typeof data['turn'] === 'number' ? String(data['turn']) : undefined;
|
|
215
278
|
const stepId = typeof data['step'] === 'number' ? String(data['step']) : undefined;
|
|
216
279
|
switch (event.type) {
|
|
@@ -229,6 +292,11 @@ export function startLiveRender(deps) {
|
|
|
229
292
|
runtime.headFlush = undefined;
|
|
230
293
|
runtime.headAttempted = false;
|
|
231
294
|
runtime.stepSeq += 1;
|
|
295
|
+
// A step boundary is a thinking boundary: the model is reasoning
|
|
296
|
+
// about the previous step's results before the next tool call.
|
|
297
|
+
// The journal's own step number is the display truth (stepSeq is a
|
|
298
|
+
// generation fence that also bumps at the turn boundary).
|
|
299
|
+
runtime.progressPhase = { kind: 'thinking', step: typeof data['step'] === 'number' ? data['step'] : runtime.stepSeq };
|
|
232
300
|
// The previous step's finalize disposed the scheduler; a fresh one
|
|
233
301
|
// carries the new step's chunk coalescing.
|
|
234
302
|
runtime.scheduler?.dispose();
|
|
@@ -237,6 +305,7 @@ export function startLiveRender(deps) {
|
|
|
237
305
|
onFlush: flushAnswer(threadId, runtime),
|
|
238
306
|
onFlushError: (cause) => { deps.log?.('discord_live_flush_failed', { threadId, cause: String(cause) }); },
|
|
239
307
|
});
|
|
308
|
+
runtime.activityScheduler?.schedule(renderActivityContent(runtime));
|
|
240
309
|
return;
|
|
241
310
|
}
|
|
242
311
|
case 'assistant/chunk': {
|
|
@@ -265,6 +334,9 @@ export function startLiveRender(deps) {
|
|
|
265
334
|
runtime.render.setAuthoritative({ turnId, stepId, text });
|
|
266
335
|
if (interrupted)
|
|
267
336
|
runtime.render.interrupt({ turnId, stepId });
|
|
337
|
+
// The step's answer is committed: the agent is writing its reply.
|
|
338
|
+
runtime.progressPhase = { kind: 'writing' };
|
|
339
|
+
runtime.activityScheduler?.schedule(renderActivityContent(runtime));
|
|
268
340
|
runtime.scheduler?.dispose();
|
|
269
341
|
runtime.scheduler = undefined;
|
|
270
342
|
if (text === '')
|
|
@@ -319,6 +391,8 @@ export function startLiveRender(deps) {
|
|
|
319
391
|
runtime.toolNames.set(data['callId'], data['name']);
|
|
320
392
|
if (title !== undefined)
|
|
321
393
|
runtime.toolTitles.set(data['callId'], title);
|
|
394
|
+
// The status line tracks the most recent call as the running phase.
|
|
395
|
+
runtime.progressPhase = { kind: 'tool', label: data['name'], title };
|
|
322
396
|
runtime.tools.record({
|
|
323
397
|
callId: data['callId'],
|
|
324
398
|
toolName: data['name'],
|
|
@@ -333,7 +407,7 @@ export function startLiveRender(deps) {
|
|
|
333
407
|
const callId = resultCallId(data);
|
|
334
408
|
if (callId === undefined)
|
|
335
409
|
return;
|
|
336
|
-
const failed = data
|
|
410
|
+
const failed = resultFailed(data);
|
|
337
411
|
runtime.tools.record({
|
|
338
412
|
callId,
|
|
339
413
|
toolName: runtime.toolNames.get(callId) ?? 'tool',
|
|
@@ -349,16 +423,36 @@ export function startLiveRender(deps) {
|
|
|
349
423
|
runtime.scheduler = undefined;
|
|
350
424
|
runtime.activityScheduler?.dispose();
|
|
351
425
|
runtime.activityScheduler = undefined;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
426
|
+
runtime.progressPhase = undefined;
|
|
427
|
+
runtime.phaseBeforeApproval = undefined;
|
|
428
|
+
// The activity message collapses at turn end (decision 5): a turn
|
|
429
|
+
// that ran tools is EDITED into its one-line summary — the durable
|
|
430
|
+
// process trace — while a zero-tool turn keeps the pure Q&A thread
|
|
431
|
+
// and is deleted. A send still in flight when the cleanup ran would
|
|
432
|
+
// orphan the message, so the collapse waits for the flush to settle
|
|
433
|
+
// first (stepSeq fences a turn that began meanwhile: its message
|
|
434
|
+
// owns the slot and must survive).
|
|
435
|
+
const epochAtEnd = runtime.stepSeq;
|
|
436
|
+
const settledId = runtime.activityMessageId;
|
|
356
437
|
runtime.activityMessageId = undefined;
|
|
357
|
-
|
|
358
|
-
|
|
438
|
+
const summary = renderTurnSummary(runtime.tools.render());
|
|
439
|
+
const pendingActivityFlush = Promise.resolve(runtime.activityFlush).catch(() => undefined);
|
|
440
|
+
void pendingActivityFlush.then(flushedId => {
|
|
441
|
+
const id = runtime.activityMessageId ?? settledId ?? flushedId;
|
|
442
|
+
if (id === undefined || runtime.stepSeq !== epochAtEnd)
|
|
443
|
+
return;
|
|
444
|
+
runtime.activityMessageId = undefined;
|
|
445
|
+
if (summary !== undefined) {
|
|
446
|
+
const payload = buildOutboundMessage({ kind: 'tool', content: summary });
|
|
447
|
+
void deps.delivery.edit({ channelId: threadId, messageId: id, content: payload.content }).catch((cause) => {
|
|
448
|
+
deps.log?.('discord_live_activity_summary_edit_threw', { threadId, cause: String(cause) });
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
void deps.delivery.delete({ channelId: threadId, messageId: id }).catch((cause) => {
|
|
359
453
|
deps.log?.('discord_live_activity_delete_threw', { threadId, cause: String(cause) });
|
|
360
454
|
});
|
|
361
|
-
}
|
|
455
|
+
});
|
|
362
456
|
deps.onTurnEnded?.(sessionId);
|
|
363
457
|
return;
|
|
364
458
|
}
|
|
@@ -503,5 +597,30 @@ export function startLiveRender(deps) {
|
|
|
503
597
|
}
|
|
504
598
|
runtimes.clear();
|
|
505
599
|
},
|
|
600
|
+
/**
|
|
601
|
+
* Approval-wait steering for the ask patches (turn-progress spec): a
|
|
602
|
+
* claimed ask suspends the status line on the wait phase; settling
|
|
603
|
+
* restores the suspended phase so progress tracking resumes.
|
|
604
|
+
*/
|
|
605
|
+
setApprovalWait(threadId, waiting) {
|
|
606
|
+
if (state.disposed)
|
|
607
|
+
return;
|
|
608
|
+
const runtime = runtimes.get(threadId);
|
|
609
|
+
if (runtime === undefined)
|
|
610
|
+
return;
|
|
611
|
+
if (waiting) {
|
|
612
|
+
if (runtime.progressPhase?.kind === 'approval')
|
|
613
|
+
return;
|
|
614
|
+
runtime.phaseBeforeApproval = runtime.progressPhase;
|
|
615
|
+
runtime.progressPhase = { kind: 'approval' };
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
if (runtime.progressPhase?.kind !== 'approval')
|
|
619
|
+
return;
|
|
620
|
+
runtime.progressPhase = runtime.phaseBeforeApproval;
|
|
621
|
+
runtime.phaseBeforeApproval = undefined;
|
|
622
|
+
}
|
|
623
|
+
runtime.activityScheduler?.schedule(renderActivityContent(runtime));
|
|
624
|
+
},
|
|
506
625
|
};
|
|
507
626
|
}
|