feat: /last and bind button for previous Q/A context (v0.3.4)

After attaching a session, show a button and /last command that load the latest human/assistant turn via session.history so phone users can continue with context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 20:25:07 +08:00
co-authored by Cursor
parent 3566df945b
commit fb5a334edb
16 changed files with 489 additions and 11 deletions
+75 -1
View File
@@ -4,7 +4,7 @@ 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, MSG } from '../src/commands.ts'
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
type SentMessage = {
chatId: number
@@ -285,6 +285,7 @@ test('callback bind then plain text followups live agent; mirror assistant to ch
},
})
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)
@@ -444,6 +445,79 @@ test('/model lists and selects via apiProxy', async () => {
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[] = []
+3
View File
@@ -11,6 +11,8 @@ test('parse slash commands for remote control', () => {
assert.equal(parseCommand('/unbind').type, 'unbind')
assert.equal(parseCommand('/disconnect').type, 'unbind')
assert.equal(parseCommand('/model').type, 'model')
assert.equal(parseCommand('/last').type, 'last')
assert.equal(parseCommand('/context').type, 'last')
assert.equal(parseCommand('/sessions@MyBot').type, 'sessions')
assert.equal(parseCommand('/foo').type, 'unknown')
assert.equal(parseCommand('hello').type, 'plain')
@@ -19,6 +21,7 @@ test('parse slash commands for remote control', () => {
test('Chinese copy mentions sessions and bind', () => {
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
assert.ok(MSG.HELP.includes('/sessions'))
assert.ok(MSG.HELP.includes('/last'))
assert.ok(MSG.HELP.includes('/model'))
assert.ok(MSG.NEED_BIND.includes('/sessions'))
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { extractLastTurn, formatLastTurn } from '../src/history.ts'
test('extractLastTurn pairs latest human user with following assistant', () => {
const turn = extractLastTurn([
{
type: 'user/message',
data: { source: { kind: 'plugin', plugin: 'x' }, content: [{ type: 'text', text: 'injected' }] },
},
{
type: 'user/message',
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第一问' }] },
},
{
type: 'assistant/message',
data: { message: { content: [{ type: 'text', text: '第一答' }] } },
},
{
type: 'user/message',
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二问' }] },
},
{
type: 'assistant/message',
data: { message: { content: [{ type: 'text', text: '第二答' }] } },
},
])
assert.equal(turn.userText, '第二问')
assert.equal(turn.assistantText, '第二答')
})
test('formatLastTurn renders sections', () => {
const text = formatLastTurn({ userText: 'hi', assistantText: 'hello' })
assert.match(text, /【用户】/)
assert.match(text, /hi/)
assert.match(text, /【助手】/)
assert.match(text, /hello/)
})