diff --git a/README.md b/README.md
index d489be0..d4c4817 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,17 @@
## 引言
-DeepSeek Harness 自动调用工具并连续作答,虽然有插话发送但是 DeepSeek 输出太快了,拼尽全力也没有其百分之一的 tok/s,根本无法成功插话。而直接打断 DeepSeek 的输出会导致其变得胆小,甚至不再调用工具,感觉就差哭出来了。
+DeepSeek Harness 会自动调用工具并连续作答,虽然可以使用插话发送在中途提示,但是 DeepSeek 输出太快了,拼尽全力也没有其百分之一的 tok/s,根本无法成功插话。而直接打断 DeepSeek 的输出会导致其变成胆小菇,甚至不愿继续执行命令,工作积极性大幅下降。
-此插件使用了拔网线的力量,直接阻止 DeepSeek 发出下一次请求,直到你按下 Enter 或点按钮放行。暂停期间 composer 输入框照常可打字并插话发送后自动继续,或者直接回车让模型无感继续。
+为了能够成功在 DeepSeek 下一轮调用前插话并保护其弱小的心灵,此插件使用了拔网线的力量,假装卡了阻止 DeepSeek 发出下一次请求,直到你按下 Enter 或点按钮放行。暂停期间输入框照常可打字并插话发送后自动继续,或者直接回车让模型继续,这样在模型侧不会察觉到暂停的存在。
+
+恰巧做完这个插件后,`deepseek-v4.1-flash-expires-on-0910` 速度已经到 300~400 tok/s,这下拼尽全力也追不上其百分之一的 tok/s,只能使用盘外招了。
+
+注:现 deepseek-v4-flash 的速度约为 100 tok/s,而其一个中文字符约为 0.6 token,其百分之一的速度为 100 字/分钟,你的打字速度如何?
+
+## 关键词
+
+暂停 · 插话 · 无感
## 功能
@@ -75,6 +83,7 @@ dsh 把一次模型请求当作一个 step,agent 连续调用工具时会经
- 续跑判定用同一 turn 内 step 大于 1 近似,首条纯文字请求永不暂停。
- 浏览器端经 `conversation.input.right` 槽位在 composer 工具行渲染控件。
- 因 dsh 的 composer Enter 由 InputBar 私有 keymap 处理、第三方无 hook 点,暂停态用 DOM capture 拦截裸 Enter 改走放行,不改 DSH 源码。
+- 主机侧不经 `ctx.connection.rpc.handle`:该 API 自 0.1.2 起即不可用,改为向 webServer 自注册 `/dsh-pause` 前缀路由,复用 connection 的信任判定与浏览器鉴权,信封格式与官方一致。
## 开发
diff --git "a/docs/\346\232\202\345\201\234.webp" "b/docs/\346\232\202\345\201\234.webp"
index 1cb4dcc..8cff1b7 100644
Binary files "a/docs/\346\232\202\345\201\234.webp" and "b/docs/\346\232\202\345\201\234.webp" differ
diff --git a/package.json b/package.json
index d84723b..fcf87bb 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": ">=4.0.0",
- "@deepseek-ai/dsh-client-connection": "0.1.2-rc.1",
+ "@deepseek-ai/dsh-client-connection": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 359c59e..ca96c13 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,11 +16,11 @@ importers:
specifier: '>=4.0.0'
version: 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-client-connection':
- specifier: 0.1.2-rc.1
- version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
+ specifier: 0.1.5-rc.2
+ version: 0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-client-runtime':
specifier: 0.1.1-rc.2
- version: 0.1.1-rc.2(73vtgeybmbwwzeowuzsbjnuhdq)
+ version: 0.1.1-rc.2(7neua3gcqen55m6b7fcv7jskca)
'@deepseek-ai/dsh-client-ui-conversation':
specifier: 0.1.2-rc.1
version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(typescript@5.9.3)
@@ -179,8 +179,13 @@ packages:
peerDependencies:
'@deepseek-ai/cordis': ^4.0.2
- '@deepseek-ai/dsh-client-connection@0.1.2-rc.1':
- resolution: {integrity: sha512-30g7JG+Z8bDj+ux+KsDfMSXPI6zXotMAFNCxI8O8mJiutJNlZxIL5LXQeDrJpVOW9MWWQkoMp+R+Dxzp5DNDSQ==}
+ '@deepseek-ai/dsh-brand@0.1.5-rc.2':
+ resolution: {integrity: sha512-/+3TzQRYT4M8NINZ9OrsL5VWNyQSXof8mFLx3txiosWA7Bt0H6UcxUdKGXVwj6gCXhsQ74mzEHm2ucsflG0mYA==}
+ peerDependencies:
+ '@deepseek-ai/cordis': ^4.0.2
+
+ '@deepseek-ai/dsh-client-connection@0.1.5-rc.2':
+ resolution: {integrity: sha512-W0GAZX01hAfrfjoZbtwEJ5ik3dG20Hy/0TFYJ6dnvwxtcacGYNIwi3f2oHpZgLcEw+PWwq1Y9IV1iKG+yDOytg==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.2
@@ -263,11 +268,11 @@ packages:
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
- '@deepseek-ai/dsh-credentials@0.1.2-rc.1':
- resolution: {integrity: sha512-e7DGpuYQqiD4dOWGQeY/XAWPTjbax/MkrXBwsB8wBtNpoPiAKiLYgd+iFiQc66BGOlutSRFz31IKdbduqPbTxA==}
+ '@deepseek-ai/dsh-credentials@0.1.5-rc.2':
+ resolution: {integrity: sha512-TfX5MYLlyw0BFERj3dGxVfP9QGcKZUE5eXDSaY7ZfJbfQgK51VfxV5tP899OxIDRD3Bw94K5Rzx+dbUTSWJBtA==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.2
- '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1
+ '@deepseek-ai/dsh-invariants': ^0.1.5-rc.2
'@deepseek-ai/dsh-file-reference@0.1.1-rc.2':
resolution: {integrity: sha512-8Pd4SNHhV6OlbwfV5K4qmFb709I0Z68qgaUQJ7zM4BTjClNe1/dktQnETtWPfqbPTx/2sNKXM1rz4WXuROrIfQ==}
@@ -1606,20 +1611,20 @@ snapshots:
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-typert-protocol': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)
- '@deepseek-ai/dsh-api-gateway@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))':
+ '@deepseek-ai/dsh-api-gateway@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
- '@deepseek-ai/dsh-client-connection': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
+ '@deepseek-ai/dsh-client-connection': 0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-typert-protocol': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-typert-registry': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
- '@deepseek-ai/dsh-api-remotes@0.1.1-rc.2(hhxolyn32qxl77k5bx2w2l2xzm)':
+ '@deepseek-ai/dsh-api-remotes@0.1.1-rc.2(2og46zelcbzblrsvf6ubp7seci)':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-typert-protocol@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-agent-presets': 0.1.1-rc.2(wu3zztlehyvqbdo7jntxbdteli)
- '@deepseek-ai/dsh-api-gateway': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
+ '@deepseek-ai/dsh-api-gateway': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-commands': 0.1.1-rc.2(dpn3ky7k6b6rimws6dnpa4csiy)
'@deepseek-ai/dsh-cordis-host-runner': 0.1.1-rc.2(gfam5nvkfs4cbsxqaemqb5ut2y)
'@deepseek-ai/dsh-credentials': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
@@ -1636,12 +1641,12 @@ snapshots:
'@deepseek-ai/dsh-typert-protocol': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-typert-registry': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
- '@deepseek-ai/dsh-api-remotes@0.1.1-rc.2(kuw76bxtjfydp2ztulpn6va3qa)':
+ '@deepseek-ai/dsh-api-remotes@0.1.1-rc.2(wuhtdllidhbntie2tpk7ky23ki)':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-typert-protocol@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-agent-presets': 0.1.1-rc.2(wu3zztlehyvqbdo7jntxbdteli)
- '@deepseek-ai/dsh-api-gateway': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
+ '@deepseek-ai/dsh-api-gateway': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-connection@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-typert-registry@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-commands': 0.1.1-rc.2(dpn3ky7k6b6rimws6dnpa4csiy)
'@deepseek-ai/dsh-cordis-host-runner': 0.1.1-rc.2(gfam5nvkfs4cbsxqaemqb5ut2y)
'@deepseek-ai/dsh-credentials': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
@@ -1678,24 +1683,28 @@ snapshots:
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
- '@deepseek-ai/dsh-client-connection@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
+ '@deepseek-ai/dsh-brand@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
- '@deepseek-ai/dsh-credentials': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
+
+ '@deepseek-ai/dsh-client-connection@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
+ dependencies:
+ '@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
+ '@deepseek-ai/dsh-credentials': 0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/schemastery': 3.18.2
zod: 4.5.4
transitivePeerDependencies:
- '@deepseek-ai/dsh-invariants'
- '@deepseek-ai/dsh-client-runtime@0.1.1-rc.2(73vtgeybmbwwzeowuzsbjnuhdq)':
+ '@deepseek-ai/dsh-client-runtime@0.1.1-rc.2(7neua3gcqen55m6b7fcv7jskca)':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-typert-protocol@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))
- '@deepseek-ai/dsh-api-remotes': 0.1.1-rc.2(kuw76bxtjfydp2ztulpn6va3qa)
+ '@deepseek-ai/dsh-api-remotes': 0.1.1-rc.2(wuhtdllidhbntie2tpk7ky23ki)
'@deepseek-ai/dsh-attachment': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
- '@deepseek-ai/dsh-client-connection': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
+ '@deepseek-ai/dsh-client-connection': 0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-commands': 0.1.1-rc.2(dpn3ky7k6b6rimws6dnpa4csiy)
- '@deepseek-ai/dsh-host-apiproxy': 0.1.1-rc.1(gfn3eecvu56jtj22fggbs356se)
+ '@deepseek-ai/dsh-host-apiproxy': 0.1.1-rc.1(fnwvwcbgydqooshvsfzb4hntry)
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm-retry': 0.1.1-rc.2(gwq5echv3z3bhph5rhizogvk6i)
@@ -1788,10 +1797,10 @@ snapshots:
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
- '@deepseek-ai/dsh-credentials@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
+ '@deepseek-ai/dsh-credentials@0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
- '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)
+ '@deepseek-ai/dsh-brand': 0.1.5-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-file-reference@0.1.1-rc.2(nug7twkweh2ui6szc5ipei6guq)':
@@ -1821,13 +1830,13 @@ snapshots:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
- '@deepseek-ai/dsh-host-apiproxy@0.1.1-rc.1(gfn3eecvu56jtj22fggbs356se)':
+ '@deepseek-ai/dsh-host-apiproxy@0.1.1-rc.1(fnwvwcbgydqooshvsfzb4hntry)':
dependencies:
'@deepseek-ai/cordis': 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3)
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(5ej2pvpid62oktymwh6xupwfuy)
'@deepseek-ai/dsh-agent-default-model': 0.1.1-rc.2(2bp6eqhntfobeajwkp4ddjriay)
'@deepseek-ai/dsh-agent-presets': 0.1.1-rc.2(wu3zztlehyvqbdo7jntxbdteli)
- '@deepseek-ai/dsh-api-remotes': 0.1.1-rc.2(hhxolyn32qxl77k5bx2w2l2xzm)
+ '@deepseek-ai/dsh-api-remotes': 0.1.1-rc.2(2og46zelcbzblrsvf6ubp7seci)
'@deepseek-ai/dsh-attachment': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-commands': 0.1.1-rc.2(bruklq7oq4jtzy52l4zfuyww3u)
diff --git a/src/client/PauseBar.tsx b/src/client/PauseBar.tsx
index 7750697..8bd9a33 100644
--- a/src/client/PauseBar.tsx
+++ b/src/client/PauseBar.tsx
@@ -11,6 +11,7 @@
* @module dsh-pause/client
*/
import { memo, useEffect, useRef, useState } from 'react'
+import type { ReactElement } from 'react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { InputActions, InputState } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -81,6 +82,18 @@ async function releaseAndClear(
}
}
+/**
+ * 暂停图标:两条竖线,单色 currentColor 跟随主题。
+ */
+function PauseIcon(): ReactElement {
+ return (
+
+ )
+}
+
/**
* 暂停控件。暂停态挂 document capture 拦截裸 Enter,落点在本 composer 卡内时改为放行。
*/
@@ -173,8 +186,12 @@ export const PauseControl = memo(function PauseControl({
padding: '1px 8px',
fontSize: '12px',
lineHeight: '18px',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px',
}}
>
+
{enabled ? '暂停开' : '暂停'}
{paused && (
diff --git a/src/index.ts b/src/index.ts
index 9d97de2..293ec18 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,10 +1,9 @@
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import '@deepseek-ai/dsh-client-connection'
-import { transportError } from '@deepseek-ai/dsh-client-connection'
-import type { ConnectionRpcHandler, HostConnectionHandle, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection'
+import type { ConnectionRpcHandler, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import { DEFAULT_ENABLED, PauseRegistry, textToMessages } from './controller.ts'
+import { mountRpcChannel } from './rpc-channel.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
@@ -42,39 +41,37 @@ export function apply(ctx: Context, config: Config = {}): void {
const defaultEnabled = config.defaultEnabled === true
const registry = new PauseRegistry(defaultEnabled)
- const connection = (ctx as unknown as { connection?: HostConnectionHandle }).connection
- if (connection !== undefined) {
- const handler: ConnectionRpcHandler = async (endpoint, payload) => {
- try {
- switch (endpoint) {
- case 'setEnabled': {
- const { sessionId, enabled } = payload as EnableBody
- if (typeof sessionId !== 'string' || typeof enabled !== 'boolean') return rpcError('setEnabled requires sessionId:string, enabled:boolean')
- registry.setEnabled(sessionId, enabled)
- // 关闭开关时应放行任何正挂着的门,避免 driver 卡在暂停态无法前进。
- if (!enabled && registry.isPaused(sessionId)) registry.release(sessionId, '')
- return ok({ paused: registry.isPaused(sessionId) })
- }
- case 'release': {
- const { sessionId, text } = payload as ReleaseBody
- if (typeof sessionId !== 'string') return rpcError('release requires sessionId:string')
- const carried = typeof text === 'string' ? text : ''
- const released = registry.release(sessionId, carried)
- return ok({ released })
- }
- case 'status': {
- const { sessionId } = payload as StatusBody
- if (typeof sessionId !== 'string') return rpcError('status requires sessionId:string')
- return ok({ enabled: registry.enabled(sessionId), paused: registry.isPaused(sessionId) })
- }
- default: return rpcError('unknown endpoint ' + endpoint)
+ const handler: ConnectionRpcHandler = async (endpoint, payload) => {
+ try {
+ switch (endpoint) {
+ case 'setEnabled': {
+ const { sessionId, enabled } = payload as EnableBody
+ if (typeof sessionId !== 'string' || typeof enabled !== 'boolean') return rpcError('setEnabled requires sessionId:string, enabled:boolean')
+ registry.setEnabled(sessionId, enabled)
+ // 关闭开关时应放行任何正挂着的门,避免 driver 卡在暂停态无法前进。
+ if (!enabled && registry.isPaused(sessionId)) registry.release(sessionId, '')
+ return ok({ paused: registry.isPaused(sessionId) })
}
- } catch (error) {
- return rpcError(error instanceof Error ? error.message : String(error))
+ case 'release': {
+ const { sessionId, text } = payload as ReleaseBody
+ if (typeof sessionId !== 'string') return rpcError('release requires sessionId:string')
+ const carried = typeof text === 'string' ? text : ''
+ const released = registry.release(sessionId, carried)
+ return ok({ released })
+ }
+ case 'status': {
+ const { sessionId } = payload as StatusBody
+ if (typeof sessionId !== 'string') return rpcError('status requires sessionId:string')
+ return ok({ enabled: registry.enabled(sessionId), paused: registry.isPaused(sessionId) })
+ }
+ default: return rpcError('unknown endpoint ' + endpoint)
}
+ } catch (error) {
+ return rpcError(error instanceof Error ? error.message : String(error))
}
- ctx.effect(() => connection.rpc.handle('/dsh-pause', handler), 'dsh-pause: rpc')
}
+ // connection.rpc.handle 自 0.1.2-rc.1 起即不可用,登记路由时解析 webServer 会抛 without inject,故改为插件自注册通道。
+ mountRpcChannel(ctx, '/dsh-pause', handler)
ctx.on('agent/pre-step', async (payload: PreStepPayload, next: () => Promise) => {
const sessionId = payload.agent.id
diff --git a/src/rpc-channel.ts b/src/rpc-channel.ts
new file mode 100644
index 0000000..c59c31b
--- /dev/null
+++ b/src/rpc-channel.ts
@@ -0,0 +1,213 @@
+/**
+ * 在 webServer 上自注册一条 connection 风格的 RPC 通道。
+ *
+ * dsh 的 connection.rpc.handle 登记路由时会在 connection 服务自身上下文解析 webServer,
+ * 而该上下文的 inject 只有 credentials,于是任何插件调用都抛
+ * cannot get property "webServer" without inject。对照 0.1.2-rc.1 与 0.1.5-rc.2,get rpc
+ * 与 register 逐字相同、owner.webServer 行为一致,所以这不是 0.1.5 引入的回归,该 API
+ * 自 0.1.2 起即不可用;官方发行包内 handle 也没有调用点,/api 走 ctx.inject(['webServer'])
+ * 直接调 webServer.register。
+ *
+ * 自注册并非唯一出路。私有 register(owner, channel, handler) 显式接收 owner,不解析 this.ctx,
+ * 在注入了 webServer 的作用域里可用,能完整复用官方 transport。代价是它标了 private,需要
+ * 类型断言,且上游可能改名或移除。故这里选择自行实现同样的信封语义。
+ *
+ * 与官方 transport 的有意差异:
+ * - 请求体上限 8 MiB;本插件载荷只有 sessionId 与短文字,远小于官方按 maxRequestBodyBytes 的默认 300 MiB。
+ * - 信封校验手写,只认 type/method/rpcId,不用 clientRequestSchema 校验 payload 与 rpcId 形状。
+ * - handler 抛错时回 200 信封、code 为 internal,而非官方 500 纯文本;本通道 handler 自带 try,实际不可达。
+ *
+ * 本文件与 better-er/dsh-classic-coding 的 src/rpc-channel.ts 同源,dsh-live-token-stats 内也有副本;
+ * 改动鉴权、端点解析或信封语义时,三处需同步。
+ *
+ * @module dsh-pause/rpc-channel
+ */
+
+import type { IncomingMessage, ServerResponse } from 'node:http'
+
+/** RPC 处理器的统一结果信封。 */
+export type RpcChannelResult =
+ | { ok: true; value: unknown }
+ | { ok: false; error: { code: string; message: string; details: object } }
+
+/** 通道端点处理器:endpoint 与请求体,返回结果信封。 */
+export type RpcChannelHandler = (
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+) => Promise
+
+/** webServer 服务的最小面,仅注册 prefix 路由。 */
+interface WebServerFace {
+ register(route: {
+ kind: 'prefix'
+ path: string
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise
+ }): () => void
+}
+
+/** connection 服务的最小面,仅用请求信任判定。 */
+interface ConnectionFace {
+ requestRejection(request: { headers: IncomingMessage['headers'] }): 401 | 403 | undefined
+}
+
+/** 可注入上下文的最小面。 */
+interface Injectable {
+ inject(deps: string[], callback: (scope: unknown) => void): unknown
+}
+
+/** 注入作用域的最小面。 */
+interface Scope {
+ connection: ConnectionFace
+ webServer: WebServerFace
+ effect(callback: () => (() => void) | undefined, label: string): unknown
+}
+
+/** 端点路径段的合法字符集,与官方 ENDPOINT_SEGMENT_PATTERN 一致。 */
+const SEGMENT = /^[A-Za-z0-9_$.-]+$/
+/** 请求体上限。本插件载荷极小,8 MiB 已远超实际,故不采用官方 300 MiB。 */
+const MAX_BODY_BYTES = 8 * 1024 * 1024
+/** 唯一接受的 content-type,与官方信封契约一致。 */
+const JSON_MIME = 'application/json'
+
+/** 请求体超限的标记错误,由 serve 决定如何回写与断开。 */
+class PayloadTooLargeError extends Error {}
+
+/**
+ * 挂载一条 RPC 通道。webServer 或 connection 缺席时不挂载,其余功能不受影响。
+ * @param ctx - 插件上下文。
+ * @param channel - 绝对通道前缀,例如 /dsh-pause。
+ * @param handler - 端点处理器。
+ */
+export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcChannelHandler): void {
+ ctx.inject(['connection', 'webServer'], (raw) => {
+ const scope = raw as Scope
+ scope.effect(
+ () => scope.webServer.register({
+ kind: 'prefix',
+ path: channel,
+ handler: (req, res) => serve(scope.connection, channel, handler, req, res),
+ }),
+ `${channel}: rpc channel`,
+ )
+ })
+}
+
+/**
+ * 处理一次 HTTP 请求:信任与鉴权、端点解析、信封校验、结果回写。
+ * @param connection - connection 服务,用其信任判定。
+ * @param channel - 本通道前缀。
+ * @param handler - 端点处理器。
+ * @param req - node 请求。
+ * @param res - node 响应。
+ */
+async function serve(
+ connection: ConnectionFace,
+ channel: string,
+ handler: RpcChannelHandler,
+ req: IncomingMessage,
+ res: ServerResponse,
+): Promise {
+ const rejection = connection.requestRejection(req)
+ if (rejection !== undefined) {
+ res.writeHead(rejection)
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
+ return
+ }
+ if (req.method !== 'POST') return missing(res)
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
+ const prefix = `${channel}/`
+ if (!pathname.startsWith(prefix)) return missing(res)
+ const endpoint = pathname.slice(prefix.length)
+ if (!endpoint.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && SEGMENT.test(part))) return missing(res)
+
+ if (!isJsonContentType(req)) {
+ res.writeHead(415)
+ res.end('unsupported media type')
+ return
+ }
+ // 声明了超限 content-length 时先快速拒绝,不必把整份超限请求读完。
+ const declared = Number(req.headers['content-length'])
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge(req, res)
+
+ let raw: string
+ try {
+ raw = await readBody(req)
+ } catch (error) {
+ if (error instanceof PayloadTooLargeError) return tooLarge(req, res)
+ res.writeHead(400)
+ res.end('body read failed')
+ return
+ }
+ let message: { type?: unknown; rpcId?: unknown; method?: unknown; payload?: unknown }
+ try {
+ message = JSON.parse(raw) as typeof message
+ } catch (error) {
+ res.writeHead(400)
+ res.end('body is not JSON')
+ return
+ }
+ const rpcId = typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request'
+ if (message.type !== 'client-request' || typeof message.method !== 'string') {
+ return reply(res, rpcId, { ok: false, error: { code: 'gateway/bad-request', message: 'invalid client-request message', details: {} } })
+ }
+ if (message.method !== endpoint) {
+ return reply(res, rpcId, {
+ ok: false,
+ error: { code: 'gateway/bad-request', message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: {} },
+ })
+ }
+ // 客户端断开即取消,等价官方 bridge 传 request.signal 的语义。
+ const controller = new AbortController()
+ const onClose = (): void => { controller.abort(new Error('client disconnected')) }
+ res.on('close', onClose)
+ let result: RpcChannelResult
+ try {
+ result = await handler(endpoint, message.payload, controller.signal)
+ } catch (error) {
+ result = { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } }
+ } finally {
+ res.off('close', onClose)
+ }
+ reply(res, rpcId, result)
+}
+
+/** 路径不匹配或方法不符时统一 404,与官方未认领端点一致。 */
+function missing(res: ServerResponse): void {
+ res.writeHead(404)
+ res.end('not found')
+}
+
+/** 回写一个 server-response 信封。 */
+function reply(res: ServerResponse, rpcId: string, result: RpcChannelResult): void {
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ type: 'server-response', rpcId, result }))
+}
+
+/** 先回 413 再销毁连接:顺序反了 socket 已断,413 永远发不出去。 */
+function tooLarge(req: IncomingMessage, res: ServerResponse): void {
+ res.writeHead(413, { connection: 'close' })
+ res.end('payload too large')
+ req.destroy()
+}
+
+/** content-type 必须声明为 application/json,charset 等参数可忽略。 */
+function isJsonContentType(req: IncomingMessage): boolean {
+ const header = req.headers['content-type']
+ if (typeof header !== 'string') return false
+ const mime = header.split(';', 1)[0]
+ return mime !== undefined && mime.trim().toLowerCase() === JSON_MIME
+}
+
+/** 读取并限长请求体,超限抛 PayloadTooLargeError,由调用方回写 413。 */
+async function readBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = []
+ let size = 0
+ for await (const chunk of req) {
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
+ size += buffer.length
+ if (size > MAX_BODY_BYTES) throw new PayloadTooLargeError('rpc channel: request body exceeds limit')
+ chunks.push(buffer)
+ }
+ return Buffer.concat(chunks).toString('utf8')
+}
diff --git a/tests/index.spec.ts b/tests/index.spec.ts
index f946ced..7e3095e 100644
--- a/tests/index.spec.ts
+++ b/tests/index.spec.ts
@@ -3,36 +3,59 @@
* 用桩 ctx 捕获 agent/pre-step 监听与 RPC 处理器。
* 驱动「暂停 → RPC release 放行 → next() 放行该步」与「带文字放行并入 user 消息」两条路径。
*/
-import { describe, expect, it } from 'vitest'
+import http from 'node:http'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
type PreStepListener = (payload: any, next: () => Promise) => Promise
-type RpcHandler = (endpoint: string, payload: unknown) => Promise
+type RouteHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise
-/** 造一个桩 ctx:捕获 apply 注册的 pre-step 监听与 RPC handler。 */
+/** apply 自注册的路由处理器,由最近一次 harness 捕获。 */
+let routeHandler: RouteHandler | undefined
+
+const server = http.createServer((req, res) => { void routeHandler?.(req, res) })
+let port = 0
+
+beforeAll(async () => {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ port = (server.address() as { port: number }).port
+})
+
+afterAll(() => { server.close() })
+
+/** 打一次本通道 RPC,返回信封里的 result。 */
+async function rpc(method: string, payload: unknown): Promise {
+ const response = await fetch(`http://127.0.0.1:${port}/dsh-pause/${method}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'client-request', rpcId: 'test', method, payload }),
+ })
+ const body = await response.json() as { result: any }
+ return body.result
+}
+
+/** 造一个桩 ctx:捕获 apply 注册的 pre-step 监听,并接住自注册的 prefix 路由。 */
function harness(enabledDefault = false) {
let preStepListener: PreStepListener | undefined
- let rpcHandler: RpcHandler | undefined
- const effects: Array<() => unknown> = []
+ const scope = {
+ connection: { requestRejection: () => undefined },
+ webServer: {
+ register(route: { handler: RouteHandler }) {
+ routeHandler = route.handler
+ return () => {}
+ },
+ },
+ effect(cb: () => unknown) { return cb() },
+ }
const ctx = {
on(_name: string, listener: any) {
if (_name === 'agent/pre-step') preStepListener = listener as PreStepListener
return () => true
},
- effect(fn: () => unknown) { effects.push(fn) },
- connection: {
- rpc: {
- handle(_ch: string, h: any) { rpcHandler = h as RpcHandler; return () => Promise.resolve() },
- },
- },
+ inject(_deps: string[], cb: (s: unknown) => void) { cb(scope) },
}
apply(ctx as any, { defaultEnabled: enabledDefault })
- // 物化 effect 以真正调用 rpc.handle
- for (const fn of effects) void fn()
- return {
- get preStep() { return preStepListener! },
- get rpc() { return rpcHandler! },
- }
+ return { get preStep() { return preStepListener! } }
}
describe('apply host wiring', () => {
@@ -70,10 +93,10 @@ describe('apply host wiring', () => {
await Promise.resolve()
expect(nextCalledAfter).toBe(false)
// RPC status 应报 paused
- const statusBefore = await h.rpc('status', { sessionId: 's1' })
+ const statusBefore = await rpc('status', { sessionId: 's1' })
expect(statusBefore.value.paused).toBe(true)
// RPC release 空文字
- const rel = await h.rpc('release', { sessionId: 's1', text: '' })
+ const rel = await rpc('release', { sessionId: 's1', text: '' })
expect(rel.value.released).toBe(true)
const decision = await p
expect(nextCalledAfter).toBe(true)
@@ -88,7 +111,7 @@ describe('apply host wiring', () => {
async () => base,
)
await Promise.resolve()
- await h.rpc('release', { sessionId: 's2', text: ' 先别跑,我再想想 ' })
+ await rpc('release', { sessionId: 's2', text: ' 先别跑,我再想想 ' })
const decision = await p
expect(decision.kind).toBe('enter')
expect(decision.messages).toHaveLength(1)
@@ -96,14 +119,14 @@ describe('apply host wiring', () => {
})
it('无门时 release 返回 released:false', async () => {
- const h = harness(true)
- const rel = await h.rpc('release', { sessionId: 'nobody', text: '' })
+ harness(true)
+ const rel = await rpc('release', { sessionId: 'nobody', text: '' })
expect(rel.value.released).toBe(false)
})
it('RPC 参数校验返回 error', async () => {
- const h = harness(true)
- const bad = await h.rpc('release', { text: 'x' })
+ harness(true)
+ const bad = await rpc('release', { text: 'x' })
expect(bad.ok).toBe(false)
})
@@ -115,12 +138,18 @@ describe('apply host wiring', () => {
async () => base,
)
await Promise.resolve()
- const before = await h.rpc('status', { sessionId: 's3' })
+ const before = await rpc('status', { sessionId: 's3' })
expect(before.value.paused).toBe(true)
// 关闭开关:应放行门并让 pre-step 走完
- const off = await h.rpc('setEnabled', { sessionId: 's3', enabled: false })
+ const off = await rpc('setEnabled', { sessionId: 's3', enabled: false })
expect(off.value.paused).toBe(false)
const decision = await p
expect(decision).toEqual(base)
})
+
+ it('空端点返回 404', async () => {
+ harness(true)
+ const response = await fetch(`http://127.0.0.1:${port}/dsh-pause/`)
+ expect(response.status).toBe(404)
+ })
})
diff --git a/tests/real-cordis.spec.ts b/tests/real-cordis.spec.ts
new file mode 100644
index 0000000..4a892f2
--- /dev/null
+++ b/tests/real-cordis.spec.ts
@@ -0,0 +1,65 @@
+/**
+ * 真实 cordis 4.0.2 与真实 connection 服务接线冒烟:
+ * 加载真实 client-connection 插件,断言它在假 webServer 上挂出 /api,并断言 dsh-pause 在 connection 就绪后挂出自注册的 /dsh-pause 路由。
+ */
+import { describe, expect, it } from 'vitest'
+import { Context, Service } from '@deepseek-ai/cordis'
+import * as connection from '@deepseek-ai/dsh-client-connection'
+import * as pause from '../src/index.ts'
+
+/** 真实插件注册到 webServer 的 prefix 路由最小形态。 */
+interface RegisteredRoute {
+ kind: 'prefix'
+ path: string
+ handler: (req: unknown, res: unknown) => void | Promise
+}
+
+/** 最小 credentials 服务:浏览器会话密钥首次创建时授予。 */
+class FakeCredentials extends Service {
+ constructor(ctx: Context) { super(ctx, 'credentials') }
+ async modifyRecord(_key: string, update: (current: unknown) => Promise): Promise {
+ return update(undefined)
+ }
+}
+
+/** 最小 webServer 服务:记录注册的 prefix 路由。 */
+class FakeWebServer extends Service {
+ readonly routes: RegisteredRoute[] = []
+ constructor(ctx: Context) { super(ctx, 'webServer') }
+ register(route: RegisteredRoute): () => void {
+ this.routes.push(route)
+ return () => {}
+ }
+}
+
+async function settle(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 30))
+}
+
+function pathsOf(ctx: Context): string[] {
+ const webServer = (ctx as unknown as { webServer: FakeWebServer }).webServer
+ return webServer.routes.map((route) => route.path)
+}
+
+describe('真实 cordis 接线冒烟', () => {
+ it('真实 connection 服务就绪后同时挂载 /api 与 /dsh-pause', async () => {
+ const ctx = new Context()
+ await ctx.plugin(FakeCredentials)
+ await ctx.plugin(FakeWebServer)
+ await ctx.plugin(connection)
+ await settle()
+ await ctx.plugin(pause)
+ await settle()
+ const paths = pathsOf(ctx)
+ expect(paths).toContain('/api')
+ expect(paths).toContain('/dsh-pause')
+ })
+
+ it('connection 服务缺席时 dsh-pause 不挂载也不报错', async () => {
+ const ctx = new Context()
+ await ctx.plugin(FakeWebServer)
+ void ctx.plugin(pause)
+ await settle()
+ expect(pathsOf(ctx)).not.toContain('/dsh-pause')
+ })
+})
diff --git a/tests/rpc-channel.spec.ts b/tests/rpc-channel.spec.ts
new file mode 100644
index 0000000..27db0cf
--- /dev/null
+++ b/tests/rpc-channel.spec.ts
@@ -0,0 +1,226 @@
+/**
+ * rpc-channel 单元测试:伪造 connection 与 webServer 作用域,覆盖鉴权、路由、信封、content-type 与限长分支。
+ */
+import { describe, expect, it } from 'vitest'
+import { mountRpcChannel, type RpcChannelResult } from '../src/rpc-channel.ts'
+
+/** webServer 注册的 prefix 路由最小形态。 */
+interface RegisteredRoute {
+ kind: 'prefix'
+ path: string
+ handler: (req: FakeRequest, res: FakeResponse) => void | Promise
+}
+
+/** 伪造的 node 请求:headers、method、url 与可异步迭代的请求体。 */
+interface FakeRequest {
+ method: string
+ url: string
+ headers: Record
+ destroy(): void
+ [Symbol.asyncIterator](): AsyncIterator
+}
+
+/** 伪造的 node 响应:记录状态、响应头与正文,并支持 close 监听。 */
+interface FakeResponse {
+ statusCode: number
+ headers: Record | undefined
+ body: string
+ listeners: Map void)[]>
+ writeHead(code: number, headers?: Record): void
+ end(chunk?: string): void
+ on(event: string, listener: () => void): void
+ off(event: string, listener: () => void): void
+}
+
+function makeRequest(options: { method?: string; url?: string; body?: Buffer; headers?: Record } = {}): FakeRequest {
+ const chunks = options.body === undefined ? [] : [options.body]
+ return {
+ method: options.method ?? 'POST',
+ url: options.url ?? '/dsh-pause/status',
+ headers: options.headers ?? { 'content-type': 'application/json' },
+ destroy(): void {},
+ [Symbol.asyncIterator](): AsyncIterator {
+ let index = 0
+ return {
+ next: async () => index < chunks.length
+ ? { value: chunks[index++] as Buffer, done: false }
+ : { value: undefined as unknown as Buffer, done: true },
+ }
+ },
+ }
+}
+
+function makeResponse(): FakeResponse {
+ return {
+ statusCode: 0,
+ headers: undefined,
+ body: '',
+ listeners: new Map void)[]>(),
+ writeHead(code: number, headers?: Record): void {
+ this.statusCode = code
+ this.headers = headers
+ },
+ end(chunk?: string): void {
+ this.body = chunk ?? ''
+ },
+ on(event: string, listener: () => void): void {
+ const list = this.listeners.get(event) ?? []
+ list.push(listener)
+ this.listeners.set(event, list)
+ },
+ off(event: string, listener: () => void): void {
+ const list = this.listeners.get(event) ?? []
+ this.listeners.set(event, list.filter((item) => item !== listener))
+ },
+ }
+}
+
+/** 挂载一条通道,返回 webServer 收到的路由;服务缺席时返回 undefined。 */
+function mount(
+ handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise,
+ options: { rejection?: 401 | 403; connection?: boolean; webServer?: boolean } = {},
+): RegisteredRoute | undefined {
+ let route: RegisteredRoute | undefined
+ const scope = {
+ connection: { requestRejection: (): 401 | 403 | undefined => options.rejection },
+ webServer: {
+ register(registered: RegisteredRoute): () => void {
+ route = registered
+ return () => {}
+ },
+ },
+ effect(callback: () => (() => void) | undefined): unknown {
+ return callback()
+ },
+ }
+ const ctx = {
+ inject(deps: string[], callback: (raw: unknown) => void): unknown {
+ if (options.connection === false && deps.includes('connection')) return undefined
+ if (options.webServer === false && deps.includes('webServer')) return undefined
+ callback(scope)
+ return undefined
+ },
+ }
+ mountRpcChannel(ctx, '/dsh-pause', handler)
+ return route
+}
+
+const okHandler = async (): Promise => ({ ok: true, value: { paused: true } })
+
+/** 组装一个合法 client-request 信封。 */
+function envelope(overrides: Record = {}): Buffer {
+ return Buffer.from(JSON.stringify({ type: 'client-request', rpcId: 'pause-1', method: 'status', payload: {}, ...overrides }))
+}
+
+describe('mountRpcChannel', () => {
+ it('合法请求回写 server-response 信封并在结束后摘除 close 监听', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: envelope() }), res)
+ expect(res.statusCode).toBe(200)
+ expect(JSON.parse(res.body)).toEqual({
+ type: 'server-response',
+ rpcId: 'pause-1',
+ result: { ok: true, value: { paused: true } },
+ })
+ expect(res.listeners.get('close') ?? []).toHaveLength(0)
+ })
+
+ it('鉴权拒绝原样透传 401', async () => {
+ const route = mount(okHandler, { rejection: 401 })!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: envelope() }), res)
+ expect(res.statusCode).toBe(401)
+ expect(res.body).toBe('unauthorized')
+ })
+
+ it('鉴权拒绝原样透传 403', async () => {
+ const route = mount(okHandler, { rejection: 403 })!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: envelope() }), res)
+ expect(res.statusCode).toBe(403)
+ expect(res.body).toBe('forbidden')
+ })
+
+ it('非 POST 返回 404', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ method: 'GET', body: envelope() }), res)
+ expect(res.statusCode).toBe(404)
+ })
+
+ it('路径不匹配返回 404', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ url: '/other/status', body: envelope() }), res)
+ expect(res.statusCode).toBe(404)
+ })
+
+ it('空端点返回 404', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ url: '/dsh-pause/', body: envelope() }), res)
+ expect(res.statusCode).toBe(404)
+ })
+
+ it('端点段含非法字符返回 404', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ url: '/dsh-pause/a@b', body: envelope() }), res)
+ expect(res.statusCode).toBe(404)
+ })
+
+ it('content-type 非 application/json 返回 415', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: envelope(), headers: { 'content-type': 'text/plain' } }), res)
+ expect(res.statusCode).toBe(415)
+ })
+
+ it('信封不合法回 bad-request 且保留 rpcId', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: Buffer.from(JSON.stringify({ rpcId: 'pause-9', method: 'status' })) }), res)
+ expect(res.statusCode).toBe(200)
+ const parsed = JSON.parse(res.body) as { rpcId: string; result: { ok: boolean; error: { code: string } } }
+ expect(parsed.rpcId).toBe('pause-9')
+ expect(parsed.result.ok).toBe(false)
+ expect(parsed.result.error.code).toBe('gateway/bad-request')
+ })
+
+ it('method 与端点不一致回 bad-request', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ body: envelope({ method: 'release' }) }), res)
+ const parsed = JSON.parse(res.body) as { result: { ok: boolean; error: { message: string } } }
+ expect(parsed.result.ok).toBe(false)
+ expect(parsed.result.error.message).toContain('does not match endpoint')
+ })
+
+ it('请求体超限回 413、带 connection: close 并断开请求', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ let destroyed = false
+ const req = makeRequest({ body: Buffer.alloc(8 * 1024 * 1024 + 1) })
+ req.destroy = (): void => { destroyed = true }
+ await route.handler(req, res)
+ expect(res.statusCode).toBe(413)
+ expect(res.headers?.connection).toBe('close')
+ expect(destroyed).toBe(true)
+ })
+
+ it('content-length 预声明超限即快速 413', async () => {
+ const route = mount(okHandler)!
+ const res = makeResponse()
+ await route.handler(makeRequest({ headers: { 'content-type': 'application/json', 'content-length': String(8 * 1024 * 1024 + 1) } }), res)
+ expect(res.statusCode).toBe(413)
+ })
+
+ it('webServer 缺席时不挂载通道', () => {
+ expect(mount(okHandler, { webServer: false })).toBeUndefined()
+ })
+
+ it('connection 缺席时不挂载通道', () => {
+ expect(mount(okHandler, { connection: false })).toBeUndefined()
+ })
+})