dsh-remote-bridge 2.2.1 → 2.2.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.
@@ -1,7 +1,7 @@
1
1
  asyncapi: 3.0.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile Events
4
- version: 2.2.1
4
+ version: 2.2.2
5
5
  description: Live file and authorized DSH chat events. Reconnect clients must refresh REST state; unbounded replay is not provided.
6
6
  servers:
7
7
  local:
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile API
4
- version: 2.2.1
4
+ version: 2.2.2
5
5
  description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
6
6
  servers:
7
7
  - url: http://127.0.0.1:3090/api/v1
@@ -582,6 +582,102 @@ paths:
582
582
  responses:
583
583
  '202': { description: Decision accepted by DSH. }
584
584
  '409': { $ref: '#/components/responses/Error' }
585
+ /chat/sessions/{sessionId}/questions:
586
+ get:
587
+ operationId: listUserQuestions
588
+ description: |
589
+ Lists questions an agent is waiting on, from two sources merged together:
590
+ the ones this bridge is holding open (an `ask_user_question` call with no
591
+ `timeout`, taken over from the host's `user-questions/request` waterfall),
592
+ and the ones the kernel still accepts a late answer for (`continued`).
593
+ A question held open is reported first; the same `callId` never appears twice.
594
+ The `viaBridge` flag distinguishes the two. An unknown or inactive session
595
+ yields an empty list rather than an error.
596
+ parameters:
597
+ - name: sessionId
598
+ in: path
599
+ required: true
600
+ schema: { type: string }
601
+ responses:
602
+ '200':
603
+ description: Questions the current device may answer.
604
+ content:
605
+ application/json:
606
+ schema:
607
+ type: object
608
+ required: [items]
609
+ properties:
610
+ items:
611
+ type: array
612
+ items:
613
+ type: object
614
+ required: [callId, state, questions]
615
+ properties:
616
+ callId: { type: string }
617
+ state: { type: string, enum: [continued] }
618
+ viaBridge:
619
+ type: boolean
620
+ description: True when the bridge is holding this question open, so the agent is blocked on it right now.
621
+ questions:
622
+ type: array
623
+ items:
624
+ type: object
625
+ required: [id, question]
626
+ properties:
627
+ id: { type: string }
628
+ question: { type: string }
629
+ header: { type: string }
630
+ detail: { type: string }
631
+ multiSelect: { type: boolean }
632
+ options:
633
+ type: array
634
+ items:
635
+ type: object
636
+ required: [label]
637
+ properties:
638
+ label: { type: string }
639
+ description: { type: string }
640
+ '404': { $ref: '#/components/responses/Error' }
641
+ /chat/sessions/{sessionId}/questions/{callId}/answer:
642
+ post:
643
+ operationId: answerUserQuestion
644
+ description: |
645
+ Answers one question set. Answers for a question the bridge holds open are
646
+ delivered as the return value of the tool call that is waiting; answers for a
647
+ `continued` question are steered into the session as a new turn. The
648
+ `selected` array carries option labels and must match the question's own
649
+ options; use `custom` for free text.
650
+ parameters:
651
+ - name: sessionId
652
+ in: path
653
+ required: true
654
+ schema: { type: string }
655
+ - name: callId
656
+ in: path
657
+ required: true
658
+ schema: { type: string }
659
+ requestBody:
660
+ required: true
661
+ content:
662
+ application/json:
663
+ schema:
664
+ type: object
665
+ required: [answers]
666
+ properties:
667
+ answers:
668
+ type: array
669
+ items:
670
+ type: object
671
+ required: [id, selected]
672
+ properties:
673
+ id: { type: string }
674
+ selected: { type: array, items: { type: string } }
675
+ custom: { type: string }
676
+ responses:
677
+ '202': { description: Answer accepted. }
678
+ '400': { $ref: '#/components/responses/Error' }
679
+ '409': { $ref: '#/components/responses/Error' }
680
+ '503': { $ref: '#/components/responses/Error' }
585
681
  /chat/sessions/{sessionId}/commands:
586
682
  get:
587
683
  operationId: listSessionCommands
package/lib/index.d.ts CHANGED
@@ -337,6 +337,36 @@ interface DshApiProxy {
337
337
  agentPreset: string;
338
338
  }>>;
339
339
  };
340
+ /**
341
+ * 用户问题(内核 `ctx.userQuestions`,即 ask_user_question 工具)。
342
+ *
343
+ * 为什么单开一个命名空间而不是塞进 `sessions`:它由**另一个内核服务**提供
344
+ * (`@deepseek-ai/dsh-user-questions`),在一条没装它的 profile 上是**可选能力** ——
345
+ * 分开之后,「这个能力不在」和「这个会话有问题」在类型上就分得开。
346
+ */
347
+ userQuestions: {
348
+ /**
349
+ * 列出还能回答的问题(内核 `continued(agent)`)。
350
+ * 没有 live agent、或没有该服务时返回空列表 —— 冷会话是常态,不该报错。
351
+ */
352
+ list(request: {
353
+ rpcId: string;
354
+ payload: {
355
+ sessionId: string;
356
+ };
357
+ }): Promise<RpcResponse<{
358
+ items: UserQuestionView[];
359
+ }>>;
360
+ /** 提交答案;失败原因见 [UserQuestionAnswerOutcome]。 */
361
+ answer(request: {
362
+ rpcId: string;
363
+ payload: {
364
+ sessionId: string;
365
+ callId: string;
366
+ answer: UserQuestionAnswer;
367
+ };
368
+ }): Promise<RpcResponse<UserQuestionAnswerOutcome>>;
369
+ };
340
370
  events: {
341
371
  mux(request: {
342
372
  rpcId: string;
@@ -468,6 +498,60 @@ interface PendingApprovalView {
468
498
  risk: 'standard' | 'full-access';
469
499
  requestedAt: number;
470
500
  }
501
+ /**
502
+ * 一条「还能回答」的用户问题 —— 内核 `userQuestions.continued()` 的一行。
503
+ *
504
+ * 为什么类型是宽松的、而不是照抄内核的 `AskUserQuestionItem`:内核往 item 或
505
+ * question 上加过字段(`intent` 就是一例),手机端该自己决定要不要用,
506
+ * 不该等插件跟着发版。所以这里只钉住三个**契约字段**,其余原样透传。
507
+ */
508
+ interface UserQuestionView {
509
+ callId: string;
510
+ /** 内核 `UserQuestionState`:'open' 或 'continued';本端点只返回 continued。 */
511
+ state: string;
512
+ /** 问题项**原样**透传(id / question / detail? / header? / options? / multiSelect? / intent?)。 */
513
+ questions: unknown[];
514
+ /** 内核以后加的字段照样透传,不在这里裁剪。 */
515
+ [key: string]: unknown;
516
+ }
517
+ /** 一条问题的答案(内核 `AskUserQuestionAnswerItem`)。 */
518
+ interface UserQuestionAnswerItem {
519
+ /** 被回答的问题 id,必须与请求里的问题 id 对上。 */
520
+ id: string;
521
+ /** 选中的选项 label;多选时可能多项。 */
522
+ selected: string[];
523
+ /** 可选的「其他」自由文本。 */
524
+ custom?: string;
525
+ }
526
+ /** 一次提交的完整答案批次(内核 `AskUserQuestionAnswer`):一次调用里的每个问题各占一项。 */
527
+ interface UserQuestionAnswer {
528
+ answers: UserQuestionAnswerItem[];
529
+ }
530
+ /**
531
+ * 「把答案送进内核」的结果。
532
+ *
533
+ * 刻意**不走 RpcResponse 的 error 通道**:`unwrapDsh` 只认 `not-found` /
534
+ * `busy` / `locked` 三种 code 去映射 HTTP 状态,其余一律 400 —— 而这里需要区分
535
+ * 409 / 400 / 503。做成**值**之后由 adapter 决定 HTTP 状态,
536
+ * 和 approvals 的 `respond()` 返回 `{ accepted: false, reason }` 是同一个套路。
537
+ */
538
+ type UserQuestionAnswerFailure =
539
+ /** 内核里没有这个 callId 的悬空问题(`answer()` 返回了 false)。 */
540
+ 'not-pending' |
541
+ /** 已经有一条回复在排队(内核 `REPLY_QUEUED`)。重复提交不是服务器故障。 */
542
+ 'reply-queued' |
543
+ /** 答案批次没有「一问一答」地对齐(内核 `BAD_ANSWER`)。 */
544
+ 'answer-invalid' |
545
+ /** 这个 agent 现在不接受人工回答(非 live root / 被别的 agent 拥有),或会话没有 live agent。 */
546
+ 'not-answerable' |
547
+ /** 这套 DSH 构建里压根没有 `ctx.userQuestions` 服务。 */
548
+ 'unavailable';
549
+ type UserQuestionAnswerOutcome = {
550
+ accepted: true;
551
+ } | {
552
+ accepted: false;
553
+ reason: UserQuestionAnswerFailure;
554
+ };
471
555
  interface ChatWorkspaceView {
472
556
  id: string;
473
557
  title: string;
@@ -556,6 +640,22 @@ declare class DshChatAdapter {
556
640
  archiveSession(principal: Principal, sessionId: string): Promise<string[]>;
557
641
  listPendingApprovals(principal: Principal, sessionId: string): Promise<PendingApprovalView[]>;
558
642
  decideApproval(principal: Principal, sessionId: string, approvalId: string, outcome: 'allowed-once' | 'rejected'): Promise<PendingApprovalView>;
643
+ /**
644
+ * 列出「agent 正在等、且还能回答」的用户问题。
645
+ *
646
+ * 口径与 [listPendingApprovals] 一致:先 requireSession(会话对本设备不可见 → 404),
647
+ * 再问内核;字段一个都不裁 —— 客户端照着渲染,内核加字段不用改插件。
648
+ */
649
+ listUserQuestions(principal: Principal, sessionId: string): Promise<UserQuestionView[]>;
650
+ /**
651
+ * 把一次答案提交给内核。
652
+ *
653
+ * 失败原因全部由 `api.userQuestions.answer` 以**值**给出(见 [UserQuestionAnswerOutcome]),
654
+ * 这里翻成 HTTP 语义:503 能力缺失 / 409 状态冲突 / 400 批次不合法。
655
+ * 为什么不在 bridge 里直接抛:`unwrapDsh` 只把 `not-found` / `busy` / `locked`
656
+ * 映成 404/409,其余一律 400 —— 表达不了这几种区别。
657
+ */
658
+ answerUserQuestion(principal: Principal, sessionId: string, callId: string, answer: UserQuestionAnswer): Promise<void>;
559
659
  models(principal: Principal, sessionId: string): Promise<SessionModelsView>;
560
660
  selectModel(principal: Principal, sessionId: string, selection: ModelSelectionView): Promise<ModelSelectionView>;
561
661
  listCommands(principal: Principal, sessionId: string): Promise<CommandDescriptorView[]>;
package/lib/index.js CHANGED
@@ -513,6 +513,46 @@ var DshChatAdapter = class {
513
513
  this.pendingApprovals.delete(key);
514
514
  return publicApproval(pending);
515
515
  }
516
+ /**
517
+ * 列出「agent 正在等、且还能回答」的用户问题。
518
+ *
519
+ * 口径与 [listPendingApprovals] 一致:先 requireSession(会话对本设备不可见 → 404),
520
+ * 再问内核;字段一个都不裁 —— 客户端照着渲染,内核加字段不用改插件。
521
+ */
522
+ async listUserQuestions(principal, sessionId) {
523
+ await this.requireSession(principal, sessionId);
524
+ return unwrapDsh(await this.api.userQuestions.list({
525
+ rpcId: randomUUID(),
526
+ payload: { sessionId }
527
+ })).items;
528
+ }
529
+ /**
530
+ * 把一次答案提交给内核。
531
+ *
532
+ * 失败原因全部由 `api.userQuestions.answer` 以**值**给出(见 [UserQuestionAnswerOutcome]),
533
+ * 这里翻成 HTTP 语义:503 能力缺失 / 409 状态冲突 / 400 批次不合法。
534
+ * 为什么不在 bridge 里直接抛:`unwrapDsh` 只把 `not-found` / `busy` / `locked`
535
+ * 映成 404/409,其余一律 400 —— 表达不了这几种区别。
536
+ */
537
+ async answerUserQuestion(principal, sessionId, callId, answer) {
538
+ await this.requireSession(principal, sessionId);
539
+ const outcome = unwrapDsh(await this.api.userQuestions.answer({
540
+ rpcId: randomUUID(),
541
+ payload: {
542
+ sessionId,
543
+ callId,
544
+ answer
545
+ }
546
+ }));
547
+ if (outcome.accepted) return;
548
+ switch (outcome.reason) {
549
+ case "unavailable": throw new ApiError(503, "USER_QUESTIONS_UNAVAILABLE", "This DSH build provides no ctx.userQuestions service, so questions cannot be answered.");
550
+ case "not-pending": throw new ApiError(409, "QUESTION_NOT_PENDING", "This question is no longer pending.");
551
+ case "reply-queued": throw new ApiError(409, "QUESTION_REPLY_QUEUED", "A reply is already queued for this question.");
552
+ case "not-answerable": throw new ApiError(409, "QUESTION_NOT_ANSWERABLE", "This session cannot answer questions right now: its agent is not the live root.");
553
+ case "answer-invalid": throw new ApiError(400, "QUESTION_ANSWER_INVALID", "The answer batch must name each question of the call exactly once.");
554
+ }
555
+ }
516
556
  async models(principal, sessionId) {
517
557
  await this.requireSession(principal, sessionId);
518
558
  return unwrapDsh(await this.api.sessions.models({
@@ -1187,6 +1227,72 @@ function optionalService(host, name) {
1187
1227
  return null;
1188
1228
  }
1189
1229
  }
1230
+ /**
1231
+ * 容错取 Agent 作用域上的 `userQuestions` 服务;拿不到就返回 `null`。
1232
+ *
1233
+ * 依据(全部实测,不是推断):
1234
+ * - `agent.ctx` 是内核声明的字段:`@deepseek-ai/dsh-agent` 的
1235
+ * `lib/types/runtime-types.d.ts`(`interface Agent { readonly ctx: Context }`)。
1236
+ * - `ctx.get(name)` 由 cordis 混入 `ctx`:`@deepseek-ai/cordis/lib/index.js` 里
1237
+ * `this.mixin("reflect", ["get", "set", "provide", "accessor", "mixin"])`;
1238
+ * 同一文件的 `get(name, strict = true)` 在没人提供该服务时返回 `undefined`,**不抛**。
1239
+ * - 内核自己也这么取:`dsh-user-questions/lib/types/index.js` 里
1240
+ * `this.ctx.get('agents')` / `this.ctx.get('sessionProjections')`。
1241
+ *
1242
+ * 即便如此仍做三层防御(ctx 形状 → get 是不是函数 → try/catch):这是**可选能力**,
1243
+ * 没装它的 profile 只该少一个功能,不该让整个插件激活失败 —— attachments /
1244
+ * fileUploads 已经用两次线上事故教过这个道理。
1245
+ */
1246
+ function userQuestionsOf(agent) {
1247
+ if (agent === null || typeof agent !== "object") return null;
1248
+ const ctx = agent.ctx;
1249
+ if (ctx === null || ctx === void 0 || typeof ctx !== "object") return null;
1250
+ const get = ctx.get;
1251
+ if (typeof get !== "function") return null;
1252
+ try {
1253
+ const service = get.call(ctx, "userQuestions");
1254
+ return service !== null && typeof service === "object" ? service : null;
1255
+ } catch {
1256
+ return null;
1257
+ }
1258
+ }
1259
+ /**
1260
+ * 内核的一行 `PendingUserQuestion` → 对外 view。
1261
+ *
1262
+ * 只钉三个契约字段(callId / state / questions),其余**原样透传**:内核以后往 item
1263
+ * 或 question 上加字段,手机端自己决定要不要用,插件不用跟着发版。
1264
+ * 缺 callId 或 questions 的行直接丢掉,而不是把残缺的行发给客户端。
1265
+ */
1266
+ function toUserQuestionView(value) {
1267
+ if (value === null || typeof value !== "object") return void 0;
1268
+ const row = value;
1269
+ const callId = row["callId"];
1270
+ const questions = row["questions"];
1271
+ if (typeof callId !== "string" || callId === "" || !Array.isArray(questions)) return void 0;
1272
+ const state = row["state"];
1273
+ return {
1274
+ ...row,
1275
+ callId,
1276
+ state: typeof state === "string" ? state : "continued",
1277
+ questions: [...questions]
1278
+ };
1279
+ }
1280
+ /**
1281
+ * 内核异常的 `code`,取不到就 `undefined`。
1282
+ *
1283
+ * 不看 `instanceof`(跨包副本 / 跨 realm 会失手),只认 `HarnessError` 上那个
1284
+ * 「稳定、可编程路由」的 `code`:`@deepseek-ai/dsh-llm` 的 `lib/types/error.js` 里
1285
+ * `HarnessError` 就是 `this.code = code`(`name` 也被设成子类名)。
1286
+ */
1287
+ function errorCodeOf(error) {
1288
+ if (error === null || typeof error !== "object") return void 0;
1289
+ const code = error.code;
1290
+ return typeof code === "string" ? code : void 0;
1291
+ }
1292
+ /** 会话没有 live agent —— `agentFor()` 抛的正是这个 code。 */
1293
+ function isAgentUnavailable(error) {
1294
+ return errorCodeOf(error) === "session/agent-unavailable";
1295
+ }
1190
1296
  function ok(value) {
1191
1297
  return { result: {
1192
1298
  ok: true,
@@ -1443,6 +1549,175 @@ var ApprovalBridge = class {
1443
1549
  return { accepted: true };
1444
1550
  }
1445
1551
  };
1552
+ /**
1553
+ * 同一时刻最多挂起多少条前台提问:超了就把后来的直接委托给官方 UI。
1554
+ * 不做无上限的挂起表 —— 失控的 agent 能问出任意多问题,每条都吊着一个 Promise。
1555
+ */
1556
+ const MAX_PENDING_QUESTIONS = 32;
1557
+ /**
1558
+ * `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
1559
+ *
1560
+ * 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
1561
+ * 「前台限时等待 → 超时转 `continued`」那条路(`dsh-user-questions/lib/types/projection.js`
1562
+ * 的 `isTimedAskUserQuestionSchema` 查 `tool.parameters.properties.timeout`),
1563
+ * 而实测 15 个真实调用的参数键清一色只有 `questions`。也就是说绝大多数提问**不进投影**,
1564
+ * `GET /questions` 永远查不到它们;它们只经过这条 Cordis waterfall 事件
1565
+ * (`types.d.ts:136-147`)。官方 WebUI 接了这个事件 → 电脑端看得到、手机端看不到。
1566
+ *
1567
+ * 三处与 approvals 不同,都是内核契约决定的、不是选择:
1568
+ * 1. **先委托,再竞速 —— 两端都能答**:进入时立刻调一次 `next()`(把这条请求交给
1569
+ * 后面的监听者,也就是官方 WebUI 那条远端 answerer),同时自己挂一张卡给 App,
1570
+ * 然后 `Promise.race`:谁先给出答案就算谁的。
1571
+ * 这与 ApprovalBridge 不同(它是「接管 = 否决下游」,cordis `lib/index.js:307-325`
1572
+ * 的文档原话:a listener that does not call next() vetoes the rest of the chain)——
1573
+ * 审批那边先不调,问题这边先调。代价是两端各有一张卡,后答的那张会变陈旧
1574
+ * (客户端会收到 409「已不再等待」,语义正确)。
1575
+ * 2. **`this` 不能当 agent 用**:dispatcher 把它绑成 `scopeTarget(agent, agent)`
1576
+ * (`lib/types/index.js:327`),而 `Scoped<T>` 明确写着
1577
+ * 「routing-only … does not expose the subject's properties」
1578
+ * (`@deepseek-ai/dsh-scope/lib/types/index.d.ts:14-20`)。
1579
+ * 真 agent 只在 `request.agent` 里;官方 WebUI 也是另找 session
1580
+ * (`ctx.sessions.scopeOf(this)`),而不是读 `this.session`。
1581
+ * 3. 回调必须是**普通函数**:cordis 用 `callback.bind(thisArg)` 绑定作用域载体
1582
+ * (cordis `lib/index.js:263`)。本实现不依赖 `this`,但签名保持与内核契约一致。
1583
+ */
1584
+ var QuestionBridge = class {
1585
+ host;
1586
+ mux;
1587
+ pending = /* @__PURE__ */ new Map();
1588
+ constructor(host, mux) {
1589
+ this.host = host;
1590
+ this.mux = mux;
1591
+ }
1592
+ /** Register the answerer once, during plugin activation. */
1593
+ attach() {
1594
+ const bridge = this;
1595
+ this.host.on("user-questions/request", async function(...args) {
1596
+ const request = args[0];
1597
+ const next = args[args.length - 1];
1598
+ const delegate = (async () => {
1599
+ if (typeof next !== "function") throw new Error("user-questions/request was dispatched without a next() delegate");
1600
+ return next();
1601
+ })();
1602
+ delegate.catch(() => {});
1603
+ const sessionId = sessionIdOfAgent(request?.agent);
1604
+ const questions = Array.isArray(request?.questions) ? [...request.questions] : void 0;
1605
+ const callId = (typeof request?.wait?.callId === "string" && request.wait.callId !== "" ? request.wait.callId : void 0) ?? `bridge-${randomUUID()}`;
1606
+ if (sessionId === void 0 || questions === void 0 || questions.length === 0) return delegate;
1607
+ if (request?.signal?.aborted === true) return delegate;
1608
+ if (bridge.pending.size >= MAX_PENDING_QUESTIONS) return delegate;
1609
+ const rpcId = randomUUID();
1610
+ const held = new Promise((resolve) => {
1611
+ let done = false;
1612
+ const settle = (value) => {
1613
+ if (done) return;
1614
+ done = true;
1615
+ resolve(value);
1616
+ };
1617
+ bridge.pending.set(rpcId, {
1618
+ sessionId,
1619
+ callId,
1620
+ questions,
1621
+ settle
1622
+ });
1623
+ request?.signal?.addEventListener("abort", () => {
1624
+ bridge.pending.delete(rpcId);
1625
+ settle({ kind: "dismissed" });
1626
+ }, { once: true });
1627
+ bridge.mux.push({
1628
+ type: "userQuestions/requested",
1629
+ sessionId,
1630
+ callId,
1631
+ rpcId,
1632
+ questions
1633
+ });
1634
+ });
1635
+ const first = await Promise.race([delegate.then(() => ({ kind: "delegated" }), () => new Promise(() => {})), held]);
1636
+ if (first.kind === "answered") {
1637
+ bridge.pending.delete(rpcId);
1638
+ bridge.mux.push({
1639
+ type: "userQuestions/answered",
1640
+ sessionId,
1641
+ callId,
1642
+ rpcId
1643
+ });
1644
+ return first.answer;
1645
+ }
1646
+ bridge.pending.delete(rpcId);
1647
+ bridge.mux.push({
1648
+ type: "userQuestions/dismissed",
1649
+ sessionId,
1650
+ callId,
1651
+ rpcId
1652
+ });
1653
+ return await delegate;
1654
+ });
1655
+ }
1656
+ /**
1657
+ * 回答一条**正挂在前台等待**的提问(App 通过 `POST …/answer` 打进来)。
1658
+ *
1659
+ * 与 [ApprovalBridge.respond] 同形:按 (sessionId, callId) **精确**找挂起项;
1660
+ * 找不到就报 `not-pending`,绝不挑一条最近的来答 —— 答错会话的代价太大。
1661
+ */
1662
+ answer(request) {
1663
+ if (!isUserQuestionAnswerLike(request.answer)) return {
1664
+ accepted: false,
1665
+ reason: "bad-response"
1666
+ };
1667
+ for (const [rpcId, entry] of this.pending) {
1668
+ if (entry.sessionId !== request.sessionId || entry.callId !== request.callId) continue;
1669
+ this.pending.delete(rpcId);
1670
+ entry.settle({
1671
+ kind: "answered",
1672
+ answer: request.answer
1673
+ });
1674
+ return { accepted: true };
1675
+ }
1676
+ return {
1677
+ accepted: false,
1678
+ reason: "not-pending"
1679
+ };
1680
+ }
1681
+ /**
1682
+ * 某会话当前挂起中的提问(供 `GET /questions` 合并)。
1683
+ *
1684
+ * `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
1685
+ * 只认这一个值(`sets.filter { it.state == QUESTION_STATE_CONTINUED }`),
1686
+ * 报 `'open'` 等于这张卡片永远不显示 —— 而它恰恰是**此刻真正阻塞着 agent** 的那条。
1687
+ * 「这是插件接管的」由新增的 `viaBridge` 标记表达(客户端 decode 是
1688
+ * `ignoreUnknownKeys = true`,加字段安全),将来客户端想换文案/单独过滤都有依据。
1689
+ */
1690
+ pendingFor(sessionId) {
1691
+ const out = [];
1692
+ for (const entry of this.pending.values()) {
1693
+ if (entry.sessionId !== sessionId) continue;
1694
+ out.push({
1695
+ callId: entry.callId,
1696
+ state: "continued",
1697
+ questions: [...entry.questions],
1698
+ viaBridge: true
1699
+ });
1700
+ }
1701
+ return out;
1702
+ }
1703
+ };
1704
+ /**
1705
+ * 答案批次的最小校验(路由层已校验过一遍,这里是防御性第二道)。
1706
+ *
1707
+ * 只查「能不能安全喂给内核」:对象、`answers` 非空数组、每项 `id` 是非空字符串、
1708
+ * `selected` 是字符串数组。更细的「一问一答对齐」由内核自己判(BAD_ANSWER)。
1709
+ */
1710
+ function isUserQuestionAnswerLike(value) {
1711
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1712
+ const answers = value.answers;
1713
+ if (!Array.isArray(answers) || answers.length === 0) return false;
1714
+ return answers.every((item) => {
1715
+ if (item === null || typeof item !== "object" || Array.isArray(item)) return false;
1716
+ const entry = item;
1717
+ if (typeof entry.id !== "string" || entry.id === "") return false;
1718
+ return Array.isArray(entry.selected) && entry.selected.every((label) => typeof label === "string");
1719
+ });
1720
+ }
1446
1721
  /** Best-effort projection of an Agent identity to its session id. */
1447
1722
  function sessionIdOfAgent(agent) {
1448
1723
  if (!agent || typeof agent !== "object") return void 0;
@@ -1473,6 +1748,8 @@ function createDsh02ApiProxy(host) {
1473
1748
  const fileUploadService = optionalService(host, "fileUploads");
1474
1749
  const approvals = new ApprovalBridge(host, mux);
1475
1750
  approvals.attach();
1751
+ const questions = new QuestionBridge(host, mux);
1752
+ questions.attach();
1476
1753
  const relay = new SessionEventRelay(host, mux);
1477
1754
  host.on("api-session/added", (summary) => {
1478
1755
  const sessionId = sessionIdOf(summary);
@@ -1517,6 +1794,21 @@ function createDsh02ApiProxy(host) {
1517
1794
  if (agent === void 0 || agent === null) throw Object.assign(/* @__PURE__ */ new Error(`no live Agent for session ${sessionId}`), { code: "session/agent-unavailable" });
1518
1795
  return agent;
1519
1796
  }
1797
+ /**
1798
+ * `agentFor` 的宽松版:把「这个会话没有 live agent」当成**正常情况**(返回 null)。
1799
+ *
1800
+ * 为什么 list 需要它:手机端会对每个会话各拉一次问题列表,而冷会话(没在跑)没有
1801
+ * agent 是常态。把常态报成错误,客户端只会看到一片红。
1802
+ * 只放过 `session/agent-unavailable` 这一个 code,其它异常照旧往上抛。
1803
+ */
1804
+ async function agentForQuestions(sessionId) {
1805
+ try {
1806
+ return await agentFor(sessionId);
1807
+ } catch (error) {
1808
+ if (isAgentUnavailable(error)) return null;
1809
+ throw error;
1810
+ }
1811
+ }
1520
1812
  return {
1521
1813
  respond: (message) => approvals.respond(message),
1522
1814
  sessions: {
@@ -1655,6 +1947,65 @@ function createDsh02ApiProxy(host) {
1655
1947
  return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
1656
1948
  })
1657
1949
  },
1950
+ userQuestions: {
1951
+ list: (request) => call(async () => {
1952
+ const { sessionId } = request.payload;
1953
+ const bridged = questions.pendingFor(sessionId);
1954
+ const bridgedIds = new Set(bridged.map((item) => item.callId));
1955
+ const agent = await agentForQuestions(sessionId);
1956
+ if (agent === null) return { items: bridged };
1957
+ const service = userQuestionsOf(agent);
1958
+ if (service === null || typeof service.continued !== "function") return { items: bridged };
1959
+ const rows = service.continued(agent);
1960
+ const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
1961
+ return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
1962
+ }),
1963
+ answer: (request) => call(async () => {
1964
+ const { sessionId, callId, answer } = request.payload;
1965
+ const agent = await agentForQuestions(sessionId);
1966
+ let kernelCouldNotAnswer = false;
1967
+ if (agent !== null) {
1968
+ const service = userQuestionsOf(agent);
1969
+ if (service !== null && typeof service.answer === "function") {
1970
+ let answered;
1971
+ try {
1972
+ answered = await service.answer(agent, callId, answer);
1973
+ } catch (error) {
1974
+ const code = errorCodeOf(error);
1975
+ if (code === "REPLY_QUEUED") return {
1976
+ accepted: false,
1977
+ reason: "reply-queued"
1978
+ };
1979
+ if (code === "BAD_ANSWER") return {
1980
+ accepted: false,
1981
+ reason: "answer-invalid"
1982
+ };
1983
+ if (code === "CALLER_NOT_LIVE" || code === "DELEGATED_CALLER") kernelCouldNotAnswer = true;
1984
+ else throw error;
1985
+ }
1986
+ if (answered === true) return { accepted: true };
1987
+ }
1988
+ }
1989
+ const bridged = questions.answer({
1990
+ sessionId,
1991
+ callId,
1992
+ answer
1993
+ });
1994
+ if (bridged.accepted) return { accepted: true };
1995
+ if (bridged.reason === "bad-response") return {
1996
+ accepted: false,
1997
+ reason: "answer-invalid"
1998
+ };
1999
+ if (kernelCouldNotAnswer) return {
2000
+ accepted: false,
2001
+ reason: "not-answerable"
2002
+ };
2003
+ return {
2004
+ accepted: false,
2005
+ reason: "not-pending"
2006
+ };
2007
+ })
2008
+ },
1658
2009
  settings: {
1659
2010
  describe: () => call(() => host.settingsController.describe()),
1660
2011
  mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
@@ -2813,6 +3164,29 @@ var ApiRouter = class {
2813
3164
  sendJson(res, 202, { accepted: true });
2814
3165
  return;
2815
3166
  }
3167
+ const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
3168
+ if (method === "GET" && questionsRoute !== null) {
3169
+ this.options.auth.requireScope(principal, "chat.read");
3170
+ const sessionId = decodeURIComponent(questionsRoute[1]);
3171
+ sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
3172
+ return;
3173
+ }
3174
+ const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
3175
+ if (method === "POST" && questionAnswerRoute !== null) {
3176
+ this.options.auth.requireScope(principal, "chat.write");
3177
+ const sessionId = decodeURIComponent(questionAnswerRoute[1]);
3178
+ const callId = decodeURIComponent(questionAnswerRoute[2]);
3179
+ const answer = readUserQuestionAnswer(await readJson(req, 64e3));
3180
+ await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
3181
+ this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
3182
+ sessionId,
3183
+ callId,
3184
+ answers: answer.answers.length,
3185
+ questionIds: answer.answers.map((item) => item.id)
3186
+ });
3187
+ sendJson(res, 202, { accepted: true });
3188
+ return;
3189
+ }
2816
3190
  const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
2817
3191
  if (sessionModelsRoute !== null) {
2818
3192
  const sessionId = decodeURIComponent(sessionModelsRoute[1]);
@@ -3168,6 +3542,33 @@ function readPromptParts(body) {
3168
3542
  throw new ApiError(400, "BODY_INVALID", `content[${index}].type is not supported.`);
3169
3543
  });
3170
3544
  }
3545
+ /**
3546
+ * 校验一次答案提交:`{ answers: [{ id, selected[], custom? }] }`。
3547
+ *
3548
+ * 为什么要逐项校验,而不是把 body 直接透传给内核:这是**用户输入**,
3549
+ * 形状不对时内核给的报错更难懂;而且内核的 `answer()` 会检查「一问一答」的对齐,
3550
+ * 类型层面能先挡掉的就不要等到那一步。
3551
+ *
3552
+ * 入参故意收 `unknown`:`readJson` 的返回值可能是 null / 数字 / 数组,
3553
+ * 当对象取字段会抛 TypeError 变成 500 —— 非法输入必须是 400。
3554
+ */
3555
+ function readUserQuestionAnswer(body) {
3556
+ if (body === null || typeof body !== "object" || Array.isArray(body)) throw new ApiError(400, "BODY_INVALID", "The request body must be a JSON object with an answers array.");
3557
+ const answers = body.answers;
3558
+ if (!Array.isArray(answers) || answers.length === 0) throw new ApiError(400, "BODY_INVALID", "answers must be a non-empty array.");
3559
+ return { answers: answers.map((raw, index) => {
3560
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new ApiError(400, "BODY_INVALID", `answers[${index}] must be an object.`);
3561
+ const item = raw;
3562
+ if (typeof item.id !== "string" || item.id === "") throw new ApiError(400, "BODY_INVALID", `answers[${index}].id is required.`);
3563
+ if (!Array.isArray(item.selected) || item.selected.some((value) => typeof value !== "string")) throw new ApiError(400, "BODY_INVALID", `answers[${index}].selected must be an array of strings.`);
3564
+ if (item.custom !== void 0 && typeof item.custom !== "string") throw new ApiError(400, "BODY_INVALID", `answers[${index}].custom must be a string.`);
3565
+ return {
3566
+ id: item.id,
3567
+ selected: [...item.selected],
3568
+ ...item.custom === void 0 ? {} : { custom: item.custom }
3569
+ };
3570
+ }) };
3571
+ }
3171
3572
  function validateStringArray(value, name) {
3172
3573
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new ApiError(400, "BODY_INVALID", `${name} must be an array of strings.`);
3173
3574
  return [...new Set(value)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-bridge",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.19.0",