Files
dsh-telegram-channel/tests/bridge.test.ts
T
dsh-agent 9a45bde25a test: 兜底 DSH_HOME,杜绝用例把 fixture 写进真实绑定文件
tests/bridge.test.ts 有若干用例直接构造 bridge 并 bind,未设
DSH_TELEGRAM_BINDINGS_FILE,绑定落盘走 DSH_HOME 默认值 → 覆盖
/root/.dsh/telegram-channel-bindings.json(2026-09-14 实际发生,覆盖掉用户
真实绑定)。模块顶部把 DSH_HOME 指向 /tmp 兜底,显式 override 的用例不受影响。
2026-09-14 19:07:08 +08:00

1319 lines
41 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'
// 防呆(2026-09-14 事故):binding 落盘路径 = DSH_HOME/telegram-channel-bindings.json。
// 少数用例直接构造 bridge 并 bind,未设 DSH_TELEGRAM_BINDINGS_FILE,就把 fixture
// (chatId 10 / live-img-dl)写进了真实文件、覆盖掉用户真实绑定。
// 这里把 DSH_HOME 指向临时目录兜底:显式 override 的用例仍走自己的临时文件,
// 漏设的用例只会写进临时 HOME。
process.env.DSH_HOME = '/tmp/dsh-tg-test-home'
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,
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `path/${fileId}` }),
downloadFile: async () => new Uint8Array([1, 2, 3]),
...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),
},
commands: {
find: () => true,
execute: async () => ({
commandId: 'cmd-1',
result: { kind: 'success', text: 'Compacted 4 history items (~12345 tokens).' },
}),
},
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),
},
commands: {
find: () => true,
execute: async () => ({
commandId: 'cmd-busy',
result: {
kind: 'error',
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
},
}),
},
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.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes(MSG.COMPACT_BUSY)); i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text === MSG.COMPACT_BUSY), 'busy notice arrives')
})
test('/compact without registered /compact command 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),
},
commands: {
find: () => undefined, // command not registered on this host
},
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)
})
// ── 图片消息 ──
type PromptPayload = { sessionId: string; mode: string; content: Array<{ type: string; text?: string; mediaType?: string; data?: string; name?: string }> }
function photoUpdate(chatId: number, userId: number, fileId: string, opts?: {
caption?: string
mediaGroupId?: string
updateId?: number
}): TelegramUpdate {
const updateId = opts?.updateId ?? 1
return {
update_id: updateId,
message: {
message_id: updateId,
date: 0,
chat: { id: chatId, type: 'private' },
from: { id: userId },
caption: opts?.caption,
media_group_id: opts?.mediaGroupId,
photo: [
{ file_id: `${fileId}-small`, file_unique_id: `${fileId}-small`, width: 90, height: 90 },
{ file_id: fileId, file_unique_id: fileId, width: 1280, height: 960, file_size: 300000 },
],
},
}
}
function bindUpdate(chatId: number, userId: number, sessionId: string): TelegramUpdate {
return {
update_id: 1,
callback_query: {
id: 'bind',
from: { id: userId },
message: { message_id: 1, date: 0, chat: { id: chatId, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
}
}
function imageBridgeCtx(sessionId: string, prompts: PromptPayload[], promptResult?: unknown) {
const agent = makeAgent(sessionId, [])
return {
agent,
ctx: {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
apiProxy: {
sessions: {
prompt: async (req: { payload: unknown }) => {
prompts.push(req.payload as PromptPayload)
return promptResult ?? rpcOk({ accepted: true })
},
},
},
on() { return () => {} },
},
}
}
test('photo with caption is admitted via sessions.prompt (largest size, jpeg, base64)', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const jpegBytes = new Uint8Array([0xff, 0xd8, 1, 2, 3])
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `dir/${fileId}` }),
downloadFile: async (filePath: string) => {
assert.ok(filePath.includes('big-photo'), 'downloads the largest photo size')
return jpegBytes
},
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'big-photo', { caption: '看看这张图', updateId: 2 }))
assert.equal(prompts.length, 1, 'one prompt RPC')
const content = prompts[0]!.content
assert.deepEqual(content[0], { type: 'text', text: '看看这张图' })
assert.equal(content[1]?.type, 'image')
assert.equal(content[1]?.mediaType, 'image/jpeg')
assert.equal(content[1]?.data, Buffer.from(jpegBytes).toString('base64'))
assert.ok(!sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'no unsupported notice')
})
test('image document (png) is admitted with its file name', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-doc'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: {
file_id: 'doc-png',
file_unique_id: 'doc-png',
file_name: 'screenshot.png',
mime_type: 'image/png',
file_size: 5000,
},
},
})
assert.equal(prompts.length, 1)
const image = prompts[0]!.content.find((p) => p.type === 'image')
assert.equal(image?.mediaType, 'image/png')
assert.equal(image?.name, 'screenshot.png')
})
test('non-image document gets MEDIA_UNSUPPORTED and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-pdf'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: { file_id: 'doc-pdf', file_unique_id: 'doc-pdf', file_name: 'report.pdf', mime_type: 'application/pdf' },
},
})
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'unsupported notice sent')
})
test('album photos collapse into a single prompt with all images', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-album'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'album-1', { mediaGroupId: 'g1', updateId: 2 }))
await bridge.processUpdate(photoUpdate(10, 1, 'album-2', { mediaGroupId: 'g1', updateId: 3 }))
for (let i = 0; i < 500 && prompts.length === 0; i++) {
await new Promise((r) => setTimeout(r, 10))
}
assert.equal(prompts.length, 1, 'album debounced into one prompt')
assert.equal(prompts[0]!.content.filter((p) => p.type === 'image').length, 2)
})
test('model without image support replies IMAGE_MODEL_UNSUPPORTED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-nv'
const { ctx } = imageBridgeCtx(sessionId, prompts, {
rpcId: 'x',
result: {
ok: false,
error: {
code: 'attachment-error',
message: 'Model "deepseek-chat" does not support image input.',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
},
},
})
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'nv-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text === MSG.IMAGE_MODEL_UNSUPPORTED), 'model unsupported notice')
})
test('photo download failure replies IMAGE_FAILED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-dl'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async () => { throw new Error('telegram timeout') },
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'dl-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text.startsWith('图片发送失败。')), 'failure notice')
})
test('photo without bind prompts NEED_BIND and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
apiProxy: { sessions: { prompt: async (req: { payload: unknown }) => { prompts.push(req.payload as PromptPayload); return rpcOk({ accepted: true }) } } },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(photoUpdate(10, 1, 'unbound-photo', { updateId: 1 }))
assert.equal(prompts.length, 0)
assert.ok(sent.some((m) => m.text === MSG.NEED_BIND), 'NEED_BIND sent')
})