feat: /status 通用状态显示 + /compact 手动压缩;HTML 兼容渲染默认化

- /status:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/
  首 token 平均/输出速率/输入输出 tokens —— 与 Web 底部统计条同源
  同算法(sessionProjections: sessionStats/tokenUsage/contextPressure),
  投影插件缺失时逐组优雅降级;冷会话不强制 resume
- /compact:复用 ctx.compaction.compactNow 手动压缩绑定会话历史,
  异步执行不阻塞轮询,完成/失败均中文通知,同会话去重,
  桥停止/热重载自动中止进行中的压缩
- 全部用户可见文案不使用 emoji
- rendering 默认改 html(兼容优先)+ userQuestions/approval 双通道等
  既有修改一并合入(此前已上线确认)
- tests:+6 覆盖 /status 全字段与 /compact 各分支(51 全绿)
This commit is contained in:
RRRosmontis
2026-09-05 17:24:41 +08:00
parent 20898d5d21
commit d712fb3494
15 changed files with 1695 additions and 72 deletions
+393
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { rmSync } from 'node:fs'
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { TelegramBridge } from '../src/bridge.ts'
@@ -649,3 +650,395 @@ test('/unbind clears binding without needing create/dispose', async () => {
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
assert.equal(followups.length, 0)
})
// ── /rich: per-chat rendering mode (rich messages need a recent client) ──
function renderHarness(
id: string,
sent: SentMessage[],
rich: string[],
followups: UserMessage[],
): {
bridge: TelegramBridge
emitAssistant: (text: string) => Promise<void>
} {
const agent = makeAgent(id, followups)
let sessionListener: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (sid: ReturnType<typeof SessionId>) => (String(sid) === id ? agent : undefined),
},
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
if (event === 'session/event') sessionListener = listener
return () => {}
},
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
sendRichMessage: async (chatId, markdown) => {
rich.push(markdown)
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text: markdown }
},
}),
sleep: async () => {},
})
bridge.start()
const emitAssistant = async (text: string): Promise<void> => {
await sessionListener?.(
{ id: SessionId(id) },
{ type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } },
)
// deliver() chains async work past the listener's void return — flush.
await new Promise((r) => setTimeout(r, 0))
}
return { bridge, emitAssistant }
}
test('default rendering is HTML compat (no rich messages unless /rich on)', async () => {
const sent: SentMessage[] = []
const rich: string[] = []
const followups: UserMessage[] = []
const bindingsFile = `/tmp/dsh-tg-test-bindings-default.json`
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
try {
const { bridge, emitAssistant } = renderHarness('live-render-1', sent, rich, followups)
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'cq',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-render-1`,
},
})
await emitAssistant('default-mode reply')
assert.equal(rich.length, 0, 'no rich message without /rich on')
assert.ok(sent.some((m) => m.text.includes('default-mode reply')))
await bridge.stop()
} finally {
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
rmSync(bindingsFile, { force: true })
}
})
test('/rich on routes replies to sendRichMessage; /rich off reverts to HTML; status reports state', async () => {
const sent: SentMessage[] = []
const rich: string[] = []
const followups: UserMessage[] = []
const bindingsFile = `/tmp/dsh-tg-test-bindings-rich.json`
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
try {
const { bridge, emitAssistant } = renderHarness('live-render-2', sent, rich, followups)
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'cq',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-render-2`,
},
})
// Default: HTML compat → no rich send yet.
await emitAssistant('before toggle')
assert.equal(rich.length, 0)
// /rich (no arg) reports current state.
await bridge.processUpdate(messageUpdate(10, 1, '/rich', 2))
assert.match(sent.at(-1)!.text, /HTML 兼容/)
// /rich on → assistant replies go through sendRichMessage.
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 3))
assert.match(sent.at(-1)!.text, /已切换为富文本/)
await emitAssistant('rich-mode reply')
assert.equal(rich.length, 1)
assert.match(rich[0]!, /rich-mode reply/)
// /rich on again is idempotent (still one preference, no dupes).
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 4))
assert.ok(!sent.at(-1)!.text.includes('已切换') || rich.length === 1)
// /rich off → back to HTML compat path.
await bridge.processUpdate(messageUpdate(10, 1, '/rich off', 5))
assert.match(sent.at(-1)!.text, /已切换为 HTML/)
await emitAssistant('compat-mode reply')
assert.equal(rich.length, 1, 'no new rich send after /rich off')
assert.ok(sent.some((m) => m.text.includes('compat-mode reply')))
await bridge.stop()
} finally {
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
rmSync(bindingsFile, { force: true })
}
})
// ── /status 通用状态显示(对齐 Web 底部统计条)──
test('/status shows bind/session/workspace/model/effort/context and web-style stats', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-st'
const agent = makeAgent(sessionId, followups, { cwd: '/work/proj-a', title: '演示任务' })
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
apiProxy: {
workspace: {
list: async () => rpcOk({
items: [{
workspaceId: 'w1',
path: '/work/proj-a',
title: 'Proj A',
sessionIds: [sessionId],
}],
archivedSessionIds: [],
}),
},
sessions: {
list: async () => rpcOk({
items: [{
sessionId,
updatedAt: 1,
running: true,
blank: false,
cwd: '/work/proj-a',
}],
}),
models: async () => rpcOk({
current: { provider: 'deepseek', model: 'reasoner', reasoningEffort: 'high' },
routable: true,
groups: [],
}),
},
},
sessionProjections: {
snapshot: () => ({
asOfSeq: 0,
values: {
sessionStats: {
turns: 3,
steps: 5,
llmMs: 4200,
toolMs: 2100,
ttftMs: 1500,
ttftSteps: 5,
decodeMs: 6000,
decodeTokens: 3000,
},
tokenUsage: {
uncachedInputTokens: 3000,
cacheReadTokens: 21000,
cacheWriteTokens: 500,
outputTokens: 9000,
},
contextPressure: {
contextWindow: 131072,
projectedTokens: 64536,
},
},
}),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
// bind via legacy callback, then ask for status
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/status', 2))
const text = sent.at(-1)!.text
assert.ok(text.includes('会话 ID:live-st'), text)
assert.ok(text.includes('工作区:Proj A(/work/proj-a)'), text)
assert.ok(text.includes('模型:deepseek/reasoner'), text)
assert.ok(text.includes('思考强度:高(high)'), text)
assert.ok(text.includes('上下文:64.5K / 131K tokens(49%)'), text)
assert.ok(text.includes('3 轮 · 5 步'), text)
assert.ok(text.includes('首 token 平均 0.3s'), text)
assert.ok(text.includes('500 tok/s'), text)
assert.ok(text.includes('缓存命中'), text)
assert.ok(text.includes('输入 24.5K tok · 输出 9K tok'), text)
})
test('/status without bind prompts NEED_BIND', async () => {
const sent: SentMessage[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(messageUpdate(10, 1, '/status'))
assert.equal(sent[0]?.text, MSG.STATUS_NONE)
})
// ── /compact 手动压缩 ──
test('/compact compacts bound session and reports result', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-cc'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
compaction: {
compactNow: async () => ({
shadowedSeqs: [1, 2, 3, 4],
shadowedTokenCount: 12_345,
summarySeq: 9,
}),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
// runCompaction runs detached; wait for its result notice
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes('压缩完成')); i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text.includes('压缩完成')), 'compaction result notice arrives')
assert.ok(
sent.some((m) => m.text.includes('4 条历史记录(约 12345 tokens)')),
'result mentions shadowed range',
)
})
test('/compact busy agent replies COMPACT_BUSY without starting', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-busy'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
compaction: {
compactNow: () => {
throw Object.assign(new Error('busy'), { code: 'busy' })
},
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
sent.length = 0 // drop the BOUND message; keep only /compact replies
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.equal(sent.length, 1)
assert.equal(sent[0]!.text, MSG.COMPACT_BUSY)
})
test('/compact without compaction engine reports unavailable', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const sessionId = 'live-noeng'
const agent = makeAgent(sessionId, followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
assert.equal(sent.at(-1)!.text, MSG.COMPACT_UNAVAILABLE)
})
test('/compact without bind prompts NEED_BIND', async () => {
const sent: SentMessage[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(messageUpdate(10, 1, '/compact'))
assert.equal(sent[0]?.text, MSG.NEED_BIND)
})
+20
View File
@@ -18,6 +18,20 @@ test('parse slash commands for remote control', () => {
assert.equal(parseCommand('hello').type, 'plain')
})
test('parse /rich with optional mode argument', () => {
const bare = parseCommand('/rich')
assert.equal(bare.type, 'rich')
assert.equal(bare.type === 'rich' ? bare.arg : 'x', undefined)
const on = parseCommand('/rich on')
assert.equal(on.type, 'rich')
assert.equal(on.type === 'rich' ? on.arg : 'x', 'on')
assert.equal(parseCommand('/rich off').type === 'rich' && parseCommand('/rich off').arg, 'off')
assert.equal(parseCommand('/rich ON').type === 'rich' && parseCommand('/rich ON').arg, 'on')
assert.equal(parseCommand('/render auto').type === 'rich' && parseCommand('/render auto').arg, 'auto')
assert.equal(parseCommand('/setting').type, 'rich')
assert.equal(parseCommand('/setting html').type === 'rich' && parseCommand('/setting html').arg, 'html')
})
test('Chinese copy mentions sessions and bind', () => {
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
assert.ok(MSG.HELP.includes('/sessions'))
@@ -26,3 +40,9 @@ test('Chinese copy mentions sessions and bind', () => {
assert.ok(MSG.NEED_BIND.includes('/sessions'))
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
})
test('parse /compact', () => {
assert.equal(parseCommand('/compact').type, 'compact')
assert.equal(parseCommand('/compact@MyBot').type, 'compact')
assert.equal(parseCommand('/compact now').type, 'compact')
})