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() + }) +})