Files
dsh-telegram-channel/tests/bridge.test.ts
T
RRRosmontis d712fb3494 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 全绿)
2026-09-05 17:24:41 +08:00

1045 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
type SentMessage = {
chatId: number
text: string
parseMode?: string
replyMarkup?: InlineKeyboardMarkup
}
function fakeClient(
sent: SentMessage[],
overrides: Partial<TelegramClientLike> = {},
): TelegramClientLike {
return {
getMe: async () => ({ id: 1 }),
getUpdates: async () => [],
sendMessage: async (chatId, text, parseMode, replyMarkup) => {
sent.push({ chatId, text, parseMode, replyMarkup })
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text }
},
sendChatAction: async () => true,
answerCallbackQuery: async () => true,
setMyCommands: async () => true,
...overrides,
}
}
function messageUpdate(chatId: number, userId: number, text: string, updateId = 1): TelegramUpdate {
return {
update_id: updateId,
message: {
message_id: updateId,
date: 0,
chat: { id: chatId, type: 'private' },
from: { id: userId },
text,
},
}
}
function makeAgent(id: string, followups: UserMessage[], opts?: {
cwd?: string
title?: string
}) {
const cwd = opts?.cwd ?? `/proj/${id}`
const events = opts?.title
? [{ type: 'session/title', data: { title: opts.title } }]
: []
return {
id: SessionId(id),
session: {
header: { cwd },
meta: { cwd },
events,
},
followup(message: UserMessage) {
followups.push(message)
},
}
}
function rpcOk<T>(value: T) {
return { rpcId: 'x', result: { ok: true as const, value } }
}
test('unauthorized user gets denied', 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(99, 99, 'hi'))
assert.equal(sent[0]?.text, MSG.DENIED)
})
test('plain text without bind prompts NEED_BIND and does not create', async () => {
const sent: SentMessage[] = []
let createCalled = false
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [],
roots: () => [],
get: () => undefined,
create: async () => { createCalled = true },
},
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, 'do something'))
assert.equal(createCalled, false)
assert.equal(sent[0]?.text, MSG.NEED_BIND)
})
test('/sessions with no sessions shows NO_SESSIONS', 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, '/sessions'))
assert.equal(sent[0]?.text, MSG.NO_SESSIONS)
})
test('/sessions lists workspaces then sessions via callbacks', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-aaa', followups, {
cwd: 'D:/gitData/demo-app',
title: '演示会话',
})
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === 'live-aaa' ? 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(messageUpdate(10, 1, '/sessions'))
assert.match(sent[0]!.text, /选择工作区/)
assert.match(sent[0]!.text, /demo-app/)
assert.equal(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'ws:0')
await bridge.processUpdate({
update_id: 2,
callback_query: {
id: 'cq-ws',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: 'ws:0',
},
})
const sessionMsg = sent.at(-1)!
assert.match(sessionMsg.text, /选择会话/)
assert.match(sessionMsg.text, /演示会话/)
assert.equal(sessionMsg.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'sid:0')
})
test('/sessions via apiProxy shows all workspaces excluding archived', async () => {
const sent: SentMessage[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
apiProxy: {
workspace: {
list: async () => rpcOk({
items: [
{
workspaceId: 'w1',
path: 'D:/a',
title: 'Alpha',
sessionIds: ['s1', 's-archived'],
},
{
workspaceId: 'w2',
path: 'D:/b',
title: 'Beta',
sessionIds: ['s2'],
},
],
archivedSessionIds: ['s-archived'],
}),
},
sessions: {
list: async () => rpcOk({
items: [
{
sessionId: 's1',
updatedAt: 2,
running: true,
blank: false,
cwd: 'D:/a',
projections: { values: { title: '会话一' } },
},
{
sessionId: 's-archived',
updatedAt: 1,
running: false,
blank: false,
cwd: 'D:/a',
projections: { values: { title: '已归档' } },
},
{
sessionId: 's2',
updatedAt: 3,
running: false,
blank: false,
cwd: 'D:/b',
projections: { values: { title: '会话二' } },
},
],
}),
},
},
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, '/sessions'))
assert.match(sent[0]!.text, /Alpha/)
assert.match(sent[0]!.text, /Beta/)
assert.equal(sent[0]!.replyMarkup?.inline_keyboard?.length, 2)
})
test('callback bind then plain text followups live agent; mirror assistant to chat', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-bbb', followups)
let sessionListener: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === 'live-bbb' ? 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),
sleep: async () => {},
})
bridge.start()
await bridge.processUpdate({
update_id: 2,
callback_query: {
id: 'cq1',
from: { id: 1 },
message: {
message_id: 1,
date: 0,
chat: { id: 10, type: 'private' },
text: 'picker',
},
data: `${BIND_CB_PREFIX}live-bbb`,
},
})
assert.match(sent.at(-1)!.text, /已附着/)
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, LAST_CB)
await bridge.processUpdate(messageUpdate(10, 1, 'hello from phone', 3))
assert.equal(followups.length, 1)
assert.equal(followups[0]!.content[0]!.type, 'text')
await sessionListener?.(
{ id: SessionId('live-bbb') },
{
type: 'assistant/message',
data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } },
},
)
// Delivery is intentionally resilient now (per-chunk retry + rich→HTML
// fallback), which lengthens the async microtask chain past this listener's
// void return — flush the event loop before asserting.
await new Promise((r) => setTimeout(r, 0))
assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply')))
await bridge.stop()
})
test('cold session bind resumes then followups', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('cold-1', followups, { cwd: 'D:/proj', title: '冷会话' })
let resumed = false
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [],
roots: () => [],
get: () => (resumed ? agent : undefined),
resume: async () => {
resumed = true
return { agent, dispose: async () => {} }
},
},
apiProxy: {
workspace: {
list: async () => rpcOk({
items: [{ workspaceId: 'w', path: 'D:/proj', title: 'proj', sessionIds: ['cold-1'] }],
archivedSessionIds: [],
}),
},
sessions: {
list: async () => rpcOk({
items: [{
sessionId: 'cold-1',
updatedAt: 1,
running: false,
blank: false,
cwd: 'D:/proj',
projections: { values: { title: '冷会话' } },
}],
}),
},
},
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, '/sessions'))
await bridge.processUpdate({
update_id: 2,
callback_query: {
id: 'cq-ws',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: 'ws:0',
},
})
await bridge.processUpdate({
update_id: 3,
callback_query: {
id: 'cq-sid',
from: { id: 1 },
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
data: 'sid:0',
},
})
assert.equal(resumed, true)
assert.match(sent.at(-1)!.text, /已附着/)
await bridge.processUpdate(messageUpdate(10, 1, 'hi cold', 4))
assert.equal(followups.length, 1)
})
test('/model lists and selects via apiProxy', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-mdl', followups)
let selected: unknown
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: () => agent,
},
apiProxy: {
sessions: {
models: async () => rpcOk({
current: { provider: 'deepseek', model: 'chat' },
routable: true,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'chat', name: 'Chat' },
{ id: 'reasoner', name: 'Reasoner', reasoning: { efforts: [{ id: 'high', name: 'High' }] } },
],
}],
}),
selectModel: async (req: { payload: unknown }) => {
selected = req.payload
return rpcOk({ selected: { provider: 'deepseek', model: 'chat' } })
},
},
},
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}live-mdl`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/model', 2))
assert.match(sent.at(-1)!.text, /当前模型/)
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'mdl:0')
await bridge.processUpdate({
update_id: 3,
callback_query: {
id: 'pick',
from: { id: 1 },
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
data: 'mdl:0',
},
})
assert.deepEqual(selected, {
sessionId: 'live-mdl',
provider: 'deepseek',
model: 'chat',
})
assert.match(sent.at(-1)!.text, /已切换模型/)
})
test('/model effort picker applies reasoningEffort', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-eff', followups)
let selected: unknown
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: () => agent,
},
apiProxy: {
sessions: {
models: async () => rpcOk({
current: { provider: 'deepseek', model: 'reasoner' },
routable: true,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{
id: 'reasoner',
name: 'Reasoner',
reasoning: {
efforts: [
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
},
}],
}],
}),
selectModel: async (req: { payload: unknown }) => {
selected = req.payload
const p = req.payload as { provider: string; model: string; reasoningEffort?: string }
return rpcOk({
selected: {
provider: p.provider,
model: p.model,
reasoningEffort: p.reasoningEffort,
},
})
},
},
},
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}live-eff`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/model', 2))
await bridge.processUpdate({
update_id: 3,
callback_query: {
id: 'pick-r',
from: { id: 1 },
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
data: 'mdl:0',
},
})
assert.match(sent.at(-1)!.text, /reasoning effort|请选择/)
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'eff:0')
await bridge.processUpdate({
update_id: 4,
callback_query: {
id: 'pick-eff',
from: { id: 1 },
message: { message_id: 3, date: 0, chat: { id: 10, type: 'private' } },
data: 'eff:1',
},
})
assert.deepEqual(selected, {
sessionId: 'live-eff',
provider: 'deepseek',
model: 'reasoner',
reasoningEffort: 'max',
})
assert.match(sent.at(-1)!.text, /已切换模型/)
})
test('/last returns previous Q/A via apiProxy history', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-last', followups, { title: '有历史' })
;(agent as any).session.events = [
{
type: 'user/message',
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '手机续接前的问题' }] },
},
{
type: 'assistant/message',
data: { message: { content: [{ type: 'text', text: '电脑上的回答' }] } },
},
]
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: () => agent,
},
apiProxy: {
sessions: {
history: async () => ({
rpcId: 'h',
result: {
ok: true,
value: {
events: [
{
event: {
type: 'user/message',
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '手机续接前的问题' }] },
},
},
{
event: {
type: 'assistant/message',
data: { message: { content: [{ type: 'text', text: '电脑上的回答' }] } },
},
},
],
hasMore: false,
},
},
}),
},
},
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}live-last`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/last', 2))
const body = sent.at(-1)!.text
assert.match(body, /上次对话|用户/)
assert.match(body, /手机续接前的问题/)
assert.match(body, /电脑上的回答/)
})
test('/unbind clears binding without needing create/dispose', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-ccc', followups)
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: () => agent,
},
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: 'cq',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-ccc`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/unbind', 2))
assert.equal(sent.at(-1)?.text, MSG.UNBOUND)
await bridge.processUpdate(messageUpdate(10, 1, 'again', 3))
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)
})