From c3ca013d20ccb656432d78f9403217dcbcb1a43d Mon Sep 17 00:00:00 2001 From: jiangchen <1703935164@qq.com> Date: Sun, 27 Sep 2026 14:50:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(host):=20/window=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E4=B8=8E=20float=5Fwindow=20=E5=B7=A5=E5=85=B7=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E5=90=8C=E4=B8=80=E6=AE=B5=E5=8A=A8=E4=BD=9C=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9Bsmoke-host=20=E5=8D=87=E7=BA=A7=E4=B8=BA=2020?= =?UTF-8?q?=20=E6=9D=A1=E6=96=AD=E8=A8=80=E7=9A=84=E6=98=A0=E5=B0=84/?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=B5=8B=E8=AF=95=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=8A=93=E5=8C=85=E5=BD=A2=E7=8A=B6=EF=BC=89=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=20open=20=E8=83=BD=E6=8B=89=E8=B5=B7=E6=B5=AE?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/host.js | 35 ++++++++++--- tools/smoke-host.mjs | 118 +++++++++++++++++++++++++++++++------------ 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/lib/host.js b/lib/host.js index 8d0d8e4..3e083a6 100644 --- a/lib/host.js +++ b/lib/host.js @@ -413,6 +413,19 @@ export function apply(ctx) { } return } + // 浮窗动作(Agent 工具走同一段逻辑):open/close/pin/sizeLock/topmost/fullscreen/hide/show/status + if (url.pathname === `${ROUTE}/window` && req.method === 'POST') { + let body = {} + try { + const chunks = [] + for await (const c of req) chunks.push(c) + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + } catch { } + const r = await windowAction(body.action) + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ...r, alive: shellAlive() })) + return + } res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.end('dsh-float-window: not found') } @@ -427,6 +440,18 @@ export function apply(ctx) { return path.join(REPO, 'shell', 'run-float.cmd') } + /** 浮窗动作的唯一入口:Agent 工具和 /window 路由都走这里 */ + async function windowAction(action) { + const a = String(action || 'open') + if (a === 'status') { + const s = snapshot() + return { ok: true, text: `alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}` } + } + if (a === 'hide') broadcast({ type: 'cmd', cmd: 'hide' }) + const r = await controlFloat(a) + return { ok: !!r.ok, text: r.ok ? `${a} ok (${r.via})` : `${a} failed: ${r.error}`, via: r.via, error: r.error } + } + /** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */ function shellAlive() { try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false } @@ -478,14 +503,8 @@ export function apply(ctx) { }, output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] }, async execute(args) { - const action = String(args?.action || 'open') - if (action === 'status') { - const s = snapshot() - return `float-window: alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}` - } - if (action === 'hide') { broadcast({ type: 'cmd', cmd: 'hide' }); const r = await controlFloat('hide'); return `float-window: hide (${r.via || r.error})` } - const r = await controlFloat(action) - return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}` + const r = await windowAction(args?.action) + return `float-window: ${r.text}` }, }) status.tool = true diff --git a/tools/smoke-host.mjs b/tools/smoke-host.mjs index 5c70a7a..3fc8714 100644 --- a/tools/smoke-host.mjs +++ b/tools/smoke-host.mjs @@ -1,51 +1,107 @@ /** - * 冒烟测试:用假的 ctx 调 apply(),确认插件不会抛异常,路由能应答。 + * 冒烟 + 单元测试:用假的 ctx 调 apply(),然后 + * 1) 断言路由/SSE 应答正常; + * 2) 喂进**真实宿主里抓到的**会话事件(0.1.x / 0.1.7 两种形状),断言镜像映射与指标; + * 3) 断言 Agent 工具注册成功。 * 用法:node tools/smoke-host.mjs */ import { apply, name, inject } from '../lib/host.js' -const routes = [] -const events = [] -const effects = [] - -const ctx = { - logger: { info: (...a) => console.log('[mock]', ...a) }, - webServer: { - port: 3099, - host: '127.0.0.1', - register: (r) => { routes.push(r); return () => { } }, - }, - get: () => undefined, - set: () => { }, - on: (ev, fn) => { events.push([ev, fn]); return () => { } }, - effect: (fn) => { const d = fn(); effects.push(d); return () => { } }, +let failed = 0 +function ok(cond, label, extra = '') { + if (cond) { console.log(' PASS', label) } else { failed++; console.log(' FAIL', label, extra) } +} +function eq(actual, expected, label) { + const a = JSON.stringify(actual), e = JSON.stringify(expected) + ok(a === e, label, `expected ${e} got ${a}`) } -console.log('name =', name, '| inject =', JSON.stringify(inject)) +// ---------------------------------------------------------------- 假 ctx +const routes = [] +const listeners = [] +const tools = [] +const sessions = [] +const ctx = { + logger: { info: () => { } }, + webServer: { port: 3099, host: '127.0.0.1', register: (r) => { routes.push(r); return () => { } } }, + get: (k) => (k === 'tools' ? { register: (t) => tools.push(t) } : k === 'sessions' ? { list: () => sessions } : undefined), + provide: () => { }, set: () => { }, + on: (ev, fn) => { listeners.push([ev, fn]); return () => { } }, + effect: (fn) => { const d = fn(); return () => { } }, +} + +console.log(`plugin: ${name} inject=${JSON.stringify(inject)}`) apply(ctx) -console.log('routes =', routes.map((r) => `${r.kind}:${r.path}`).join(', ')) -console.log('events =', events.map((e) => e[0]).join(', ')) -console.log('effects =', effects.length) + +console.log('routes:', routes.map((r) => `${r.kind}:${r.path}`).join(', ')) +console.log('tools:', tools.map((t) => t.name).join(', ')) function fakeRes() { return { status: 0, headers: null, body: '', writeHead(s, h) { this.status = s; this.headers = h }, - write(chunk) { this.body += chunk; return true }, - end(chunk) { if (chunk) this.body += chunk }, + write(c) { this.body += c; return true }, + end(c) { if (c) this.body += c }, } } - const route = routes[0] -for (const p of ['/dsh-float-window/health', '/dsh-float-window/state', '/dsh-float-window/nope']) { +async function call(path, method = 'GET', body = null) { const res = fakeRes() - await route.handler({ method: 'GET', url: p, on: () => { } }, res) - console.log(p, '->', res.status, String(res.body).slice(0, 120)) + const req = { + method, url: path, on: () => { }, + async *[Symbol.asyncIterator]() { if (body) yield Buffer.from(JSON.stringify(body)) }, + } + await route.handler(req, res) + return res } -// SSE 分支:只验证不抛异常且写出了 data: -const res = fakeRes() -await route.handler({ method: 'GET', url: '/dsh-float-window/events', on: () => { } }, res) -console.log('events ->', res.status, res.headers['Content-Type'], JSON.stringify(res.body.slice(0, 80))) +// ---------------------------------------------------------------- 事件喂入 +const session = { id: 's-1', meta: { title: null } } +sessions.push(session) +const fire = (type, data, extra = {}) => listeners.forEach(([ev, fn]) => ev === 'session/event' && fn(session, { type, data, ...extra })) -console.log('SMOKE OK') +console.log('\n[1] 事件映射(真实抓包形状)') +fire('user/message', { content: [{ type: 'text', text: '你好' }], role: 'user', source: { kind: 'user' }, id: 'm1' }) +fire('user/message', { content: [{ type: 'text', text: 'SYSTEM PROMPT SNAPSHOT ...' }], role: 'user', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) +fire('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', text: '你' } }) +fire('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', text: '好' } }) +fire('assistant/message', { content: [{ type: 'text', text: '你好!' }] }) +fire('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'error', failure: { message: 'boom' } } } }) +fire('tool/call', { name: 'read', args: { path: 'a.txt' } }) +fire('session/title', { title: '测试会话' }) +fire('turn/end', {}) +fire('step/end', {}) + +const st = await call('/dsh-float-window/state') +const snap = JSON.parse(st.body) +console.log(' messages =', JSON.stringify(snap.messages)) +eq(st.status, 200, 'GET /state 200') +eq(snap.title, '测试会话', '会话标题进镜像') +eq(snap.messages.length, 5, '消息行数(系统提示被过滤、chunk 合并、错误行、tool 行)') +eq(snap.messages[0], { role: 'user', text: '你好' }, 'user/message -> 用户行') +eq(snap.messages[1], { role: 'assistant', text: '你好', streaming: true }, 'assistant/chunk 流式合并') +eq(snap.messages[2].role, 'assistant', 'assistant/message -> 助手行') +ok(snap.messages[3].text.startsWith('⚠️'), 'finish+error chunk -> 错误行') +eq(snap.messages[4].role, 'tool', 'tool/call -> 工具行') +ok(!JSON.stringify(snap.messages).includes('SYSTEM PROMPT'), '插件注入的系统提示被过滤') +eq(snap.metrics.turns, 1, 'turn/end 计数') +eq(snap.metrics.steps, 1, 'step/end 计数') + +console.log('\n[2] 路由与 SSE') +eq((await call('/dsh-float-window/health')).status, 200, 'GET /health 200') +eq((await call('/dsh-float-window/nope')).status, 404, '未知路径 404') +const sse = await call('/dsh-float-window/events') +eq(sse.status, 200, 'GET /events 200') +ok(String(sse.headers['Content-Type']).includes('text/event-stream'), 'SSE content-type') +ok(sse.body.startsWith(': connected'), 'SSE 先发注释帧') +ok(sse.body.includes('"sessionId":"s-1"'), 'SSE 首帧带第一个快照') + +console.log('\n[3] /window 与 /chat 的错误路径(不该抛异常)') +const w = await call('/dsh-float-window/window', 'POST', { action: 'status' }) +eq(w.status, 200, 'POST /window(status) 200') +ok(JSON.parse(w.body).text.includes('alive='), '/window 返回状态文本') +const c = await call('/dsh-float-window/chat', 'POST', { text: 'hi' }) +ok(JSON.parse(c.body).ok === false, '没有宿主会话 API 时 /chat 结构化失败而不是抛错') + +console.log(failed === 0 ? '\nSMOKE OK' : `\nSMOKE FAILED (${failed})`) +process.exit(failed === 0 ? 0 : 1)