feat(host): /window 路由与 float_window 工具共用同一段动作逻辑;smoke-host 升级为 20 条断言的映射/路由测试(真实抓包形状),实测 open 能拉起浮窗
This commit is contained in:
+27
-8
@@ -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
|
||||
|
||||
+87
-31
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user