dsh-model-switch 0.4.37 → 0.4.38

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## v0.4.38
2
+
3
+ 计划卡的「设置为目标并执行」现在会**真的顶掉**旧目标:宿主把 `GoalError` 经 RPC 网关中转成 `gateway/internal`,而插件只认 `GOAL_ALREADY_EXISTS`,于是 get→clear→create 的替换分支一直是死代码,点一次报一次「设置目标失败」。
4
+
5
+ - **P0 root cause: the conflict was matched on a code the gateway never sends (`src/picker/plan-goal.ts`).** `@deepseek-ai/dsh-goal` 的 `create()` 对任何非 `complete` 的现存目标 `throw new GoalError('goal "…" already exists with phase "…"', 'GOAL_ALREADY_EXISTS')`;网关把它转成 `{ code: 'gateway/internal', message: <原文> }`。插件比较的是 `error.code === 'GOAL_ALREADY_EXISTS'`,永远不成立,替换流程从未执行——用户看到的就是 `设置目标失败:gateway/internal: goal "goal-32ce…" already exists with phase "paused"`(该会话目标卡在 paused)。已核对内核实现:`clear(agent, ref)` 只做 id+revision 的 compare-and-set,**不限制 phase**,所以 active/paused/blocked 都能被顶替;`create` 只放过 `complete`。
6
+ - **Fix 1: recognise both refusal shapes.** `isAlreadyExistsError()` 先认域码,再用 `/already exists with phase/i` 识别网关转述的文本。只有这条消息才允许清旧目标——其它 `gateway/internal` 拒绝(如 agent 不存活)一律不碰现有目标,用例钉死。
7
+ - **Fix 2: transport failures no longer escape.** `create` / `get` / `clear` 全部经 `guarded()`:被 reject 的调用转成 `{ code: 'GOAL_TRANSPORT', message }` 的拒绝信封,不再冒泡成未处理异常(否则卡片丢掉错误态、计划停在待审却没有解释)。
8
+ - **Fix 3: readable messages.** `normalizeHostMessage()` 剥掉 `gateway/xxx:` 前缀,`failure()` 丢弃传输层码,卡片不会再出现双重前缀;失败信息在源头就干净,卡片无需改动。
9
+ - **Tests.** `tests/picker/plan-goal.spec.ts` 新增 6 例:网关形状的 paused / blocked 替换、非冲突网关拒绝不清目标、create / get / clear 三处 reject。`pnpm run check` 全绿(56 测试文件 / **510** 测试 + pack 闸门)。
10
+
1
11
  ## v0.4.37
2
12
 
3
13
  「能力路由」四张卡片现在说明自己的层级:摘要行是**默认(无组时)**的值,另起一行列出所有覆盖它的供应商配置组,改完组不再看起来像"没生效"。
package/lib/client.js CHANGED
@@ -3983,15 +3983,68 @@ window.__ModuleLoader__.load({
3983
3983
  }
3984
3984
  //#endregion
3985
3985
  //#region src/picker/plan-goal.ts
3986
+ /**
3987
+ * Strip the gateway's transport prefix from a Host message.
3988
+ *
3989
+ * The goal service throws `GoalError`, and the RPC gateway relays a thrown
3990
+ * error as `code: "gateway/internal"` with the original text still in
3991
+ * `message`. Showing `gateway/internal: goal "…" already exists…` to a human
3992
+ * buries the only sentence that matters, so the prefix is removed here.
3993
+ */
3994
+ function normalizeHostMessage(message) {
3995
+ return message.replace(/^\s*gateway\/[a-z0-9-]+\s*:\s*/i, "").trim();
3996
+ }
3997
+ /**
3998
+ * Whether a refused goal call means "this session already has a goal".
3999
+ *
4000
+ * Two shapes reach here. A Host that returns the domain code says
4001
+ * `GOAL_ALREADY_EXISTS`; the gateway instead relays the thrown `GoalError` as
4002
+ * `gateway/internal` and keeps only the text, so the message itself has to be
4003
+ * recognised. The domain code is checked first and the text only decides the
4004
+ * message shape the Host is known to produce — a refusal for any other reason
4005
+ * must never clear the user's current goal.
4006
+ */
4007
+ function isAlreadyExistsError(error) {
4008
+ if (error.code === "GOAL_ALREADY_EXISTS") return true;
4009
+ return /already exists with phase/i.test(error.message);
4010
+ }
4011
+ /** Codes that describe the transport, not the goal domain. */
4012
+ function isTransportCode(code) {
4013
+ return code === "GOAL_TRANSPORT" || code.startsWith("gateway/");
4014
+ }
3986
4015
  function failure(result, fallback) {
3987
4016
  if (result.ok) return { message: fallback };
3988
4017
  const { code, message } = result.error;
3989
- return {
4018
+ const text = normalizeHostMessage(message);
4019
+ const wording = text === "" ? fallback : text;
4020
+ return isTransportCode(code) ? { message: wording } : {
3990
4021
  code,
3991
- message: message.trim() === "" ? fallback : message
4022
+ message: wording
3992
4023
  };
3993
4024
  }
3994
4025
  /**
4026
+ * Run one remote call, turning a rejected promise into a refused envelope.
4027
+ *
4028
+ * Generated remotes normally resolve `{ ok: false }`, but a transport failure
4029
+ * (or an older Host that rejects instead of answering) must not escape as an
4030
+ * unhandled rejection: the card would lose its error state and the plan would
4031
+ * stay pending with no explanation.
4032
+ */
4033
+ async function guarded(call, fallback) {
4034
+ try {
4035
+ return await call();
4036
+ } catch (error) {
4037
+ const text = normalizeHostMessage(error instanceof Error ? error.message : String(error));
4038
+ return {
4039
+ ok: false,
4040
+ error: {
4041
+ code: "GOAL_TRANSPORT",
4042
+ message: text === "" ? fallback : text
4043
+ }
4044
+ };
4045
+ }
4046
+ }
4047
+ /**
3995
4048
  * Address the plan document exactly as the official opener does.
3996
4049
  *
3997
4050
  * A logged plan (`callId` present) resolves from `dsh-resource://plan/...`
@@ -4034,26 +4087,26 @@ window.__ModuleLoader__.load({
4034
4087
  message: "goal objective must be a non-empty string"
4035
4088
  };
4036
4089
  const objective = `持续推进当前plan直到完成(plan 文档:${planDocumentAddress(sessionId, ref)})`;
4037
- const created = await remote.create(sessionId, { objective });
4090
+ const created = await guarded(() => remote.create(sessionId, { objective }), "the goal could not be created");
4038
4091
  if (created.ok) return { kind: "created" };
4039
- if (created.error.code !== "GOAL_ALREADY_EXISTS") return {
4092
+ if (!isAlreadyExistsError(created.error)) return {
4040
4093
  kind: "failed",
4041
4094
  ...failure(created, "the goal could not be created")
4042
4095
  };
4043
- const current = await remote.get(sessionId);
4096
+ const current = await guarded(() => remote.get(sessionId), "the current goal could not be read for replacement");
4044
4097
  if (!current.ok || current.value === void 0 || current.value === null) return {
4045
4098
  kind: "failed",
4046
4099
  ...failure(current, "the current goal could not be read for replacement")
4047
4100
  };
4048
- const cleared = await remote.clear(sessionId, {
4101
+ const cleared = await guarded(() => remote.clear(sessionId, {
4049
4102
  id: current.value.id,
4050
4103
  revision: current.value.revision
4051
- });
4104
+ }), "the current goal could not be cleared");
4052
4105
  if (!cleared.ok) return {
4053
4106
  kind: "failed",
4054
4107
  ...failure(cleared, "the current goal could not be cleared")
4055
4108
  };
4056
- const retry = await remote.create(sessionId, { objective });
4109
+ const retry = await guarded(() => remote.create(sessionId, { objective }), "the goal could not be created");
4057
4110
  if (retry.ok) return { kind: "replaced" };
4058
4111
  return {
4059
4112
  kind: "failed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.37",
3
+ "version": "0.4.38",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",