fix: /compact 改走宿主命令通道(commands.execute),绕开隔离作用域
根因:compaction-basic 挂载在隔离作用域,插件 ctx 的 ctx.get('compaction')
解析不到(实测 undefined;agent.ctx 亦不可达)。但宿主命令运行时的按 agent
作用层已注册 /compact(command-compact,Web 端同款通道),
commands.find(agent,'compact') 可命中。
因此 /compact 改为 ctx.commands.execute(agent,'/compact',[],signal) 派发:
- 与 Web 端 /compact 完全同语义(busy/错误映射、command/run 生命周期日志)
- 结果文本按 command-compact 固定文案翻译为中文回复;无法识别时回退原文详情
- 忙会话提前提示(busySessions/phase 预检);异步执行不阻塞轮询;同会话去重
- 测试改 mock commands.find/execute(51 全绿)
This commit is contained in:
+72
-64
@@ -126,27 +126,49 @@ interface ProjectionRegistryLike {
|
||||
snapshot?: (session: unknown) => { asOfSeq: number; values: Record<string, unknown> } | undefined
|
||||
}
|
||||
|
||||
/** 手动压缩引擎(结构镜像 ctx.compaction.compactNow)。 */
|
||||
interface CompactionEngineLike {
|
||||
compactNow?: (
|
||||
/**
|
||||
* 命令运行时(结构镜像 ctx.commands.execute)。
|
||||
*
|
||||
* /compact 不在插件可见作用域内直接提供 compaction 服务(compaction-basic 挂载在
|
||||
* 隔离作用域,ctx.get 取不到),但宿主命令运行时可见,且 command-compact 已把
|
||||
* /compact 注册进该运行时的按 agent 作用层 —— Web 端正是用 execute() 派发它的。
|
||||
* TG 端复用同一条通道:语义、busy/错误映射、命令生命周期日志与 Web 完全一致。
|
||||
*/
|
||||
interface CommandsRuntimeLike {
|
||||
find?: (agent: unknown, name: string) => unknown
|
||||
execute?: (
|
||||
agent: unknown,
|
||||
line: string,
|
||||
images: readonly unknown[],
|
||||
signal: AbortSignal,
|
||||
sourceCommandId?: string,
|
||||
) => Promise<{
|
||||
shadowedSeqs: number[]
|
||||
shadowedTokenCount: number
|
||||
summarySeq: number
|
||||
} | null>
|
||||
) => Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined>
|
||||
}
|
||||
|
||||
/** 手动压缩的预期失败类(ManualCompactionError.code 的运行时字符串)。 */
|
||||
type CompactFailureCode = 'busy' | 'cancelled' | 'changed' | 'summary' | 'commit' | 'persistence'
|
||||
/** command-compact 的 CommandResult.kind 值。 */
|
||||
type CompactCommandKind = 'success' | 'error'
|
||||
|
||||
function compactErrorCode(err: unknown): CompactFailureCode | undefined {
|
||||
const code = (err as { code?: unknown } | undefined)?.code
|
||||
if (code === 'busy' || code === 'cancelled' || code === 'changed'
|
||||
|| code === 'summary' || code === 'commit' || code === 'persistence') {
|
||||
return code
|
||||
/**
|
||||
* 把 command-compact 返回的英文结果翻译成 TG 中文回复。
|
||||
* 文案以宿主 @deepseek-ai/dsh-command-compact 的固定输出为准;无法识别时返回
|
||||
* undefined,由调用方回退为「压缩失败 + 原文详情」。
|
||||
*/
|
||||
function translateCompactResult(result: { kind: string; text?: string } | undefined): string | undefined {
|
||||
const kind = result?.kind as CompactCommandKind | undefined
|
||||
const text = result?.text ?? ''
|
||||
if (kind === 'success') {
|
||||
const m = /Compacted (\d+) history items \(~(\d+) tokens\)/.exec(text)
|
||||
if (m) return MSG.COMPACT_DONE(Number(m[1]), Number(m[2]))
|
||||
if (/No compactable history yet/.test(text)) return MSG.COMPACT_NOTHING
|
||||
return undefined
|
||||
}
|
||||
if (kind === 'error') {
|
||||
if (/active compaction|not idle/.test(text)) return MSG.COMPACT_BUSY
|
||||
if (/^Compaction cancelled/.test(text)) return MSG.COMPACT_CANCELLED
|
||||
if (/history selected for compaction changed/.test(text)) return MSG.COMPACT_CHANGED
|
||||
if (/could not produce a useful summary/.test(text)) return MSG.COMPACT_SUMMARY_FAILED
|
||||
if (/did not finish cleanly/.test(text)) return MSG.COMPACT_COMMIT_FAILED
|
||||
if (/could not be saved/.test(text)) return MSG.COMPACT_PERSIST_FAILED
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -914,6 +936,11 @@ export class TelegramBridge {
|
||||
}
|
||||
|
||||
// ── /compact:手动压缩当前绑定会话(需会话空闲)──
|
||||
//
|
||||
// 压缩引擎(compaction-basic)挂载在隔离作用域,插件 ctx 拿不到;但宿主的
|
||||
// command-compact 已把 /compact 注册进命令运行时的按 agent 作用层(Web 端
|
||||
// 同款通道),因此这里复用它:语义、busy/错误映射、命令生命周期日志与 Web
|
||||
// 端 /compact 完全一致,且无需接触引擎内部。
|
||||
|
||||
private async requestCompact(chatId: number): Promise<void> {
|
||||
const binding = this.bindings.get(String(chatId))
|
||||
@@ -921,11 +948,6 @@ export class TelegramBridge {
|
||||
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||
return
|
||||
}
|
||||
const engine = this.serviceOf<CompactionEngineLike>('compaction')
|
||||
if (!engine?.compactNow) {
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
if (this.compacting.has(binding.sessionId)) {
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT)
|
||||
return
|
||||
@@ -936,24 +958,24 @@ export class TelegramBridge {
|
||||
await this.client.sendMessage(chatId, MSG.GONE)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
let operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>
|
||||
try {
|
||||
// compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。
|
||||
operation = engine.compactNow(agent as never, controller.signal)
|
||||
} catch (err) {
|
||||
const code = compactErrorCode(err)
|
||||
if (code === 'busy') {
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY)
|
||||
return
|
||||
}
|
||||
this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`)
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err)))
|
||||
const commands = this.serviceOf<CommandsRuntimeLike>('commands')
|
||||
if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'compact')) {
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
// 会话正在跑任务时压缩会被拒(runMaintenance 忙);提前提示避免「已开始→失败」两步。
|
||||
const busy = this.busySessions.has(binding.sessionId)
|
||||
|| Boolean((agent as { phase?: { kind?: string } }).phase?.kind
|
||||
&& (agent as { phase?: { kind?: string } }).phase?.kind !== 'idle')
|
||||
if (busy) {
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
this.compacting.add(binding.sessionId)
|
||||
this.compactAborts.set(binding.sessionId, controller)
|
||||
await this.client.sendMessage(chatId, MSG.COMPACT_STARTED)
|
||||
const operation = commands.execute(agent as never, '/compact', [], controller.signal)
|
||||
void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => {
|
||||
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`)
|
||||
})
|
||||
@@ -963,46 +985,32 @@ export class TelegramBridge {
|
||||
private async runCompaction(
|
||||
chatId: number,
|
||||
sessionId: string,
|
||||
operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>,
|
||||
operation: Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined>,
|
||||
controller: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await operation
|
||||
if (result === null) {
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING)
|
||||
} else {
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount))
|
||||
const execution = await operation
|
||||
if (execution === undefined) {
|
||||
// find() 通过但 execute 未命中:按未注册处理。
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
const translated = translateCompactResult(execution.result)
|
||||
if (translated !== undefined) {
|
||||
await this.enqueueNotice(chatId, translated)
|
||||
return
|
||||
}
|
||||
// 无法识别的结果:回退为通用失败 + 宿主原文。
|
||||
const raw = execution.result?.text
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(raw))
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
// 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。
|
||||
this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop')
|
||||
return
|
||||
}
|
||||
const code = compactErrorCode(err)
|
||||
switch (code) {
|
||||
case 'cancelled':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED)
|
||||
return
|
||||
case 'changed':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED)
|
||||
return
|
||||
case 'summary':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED)
|
||||
return
|
||||
case 'commit':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED)
|
||||
return
|
||||
case 'persistence':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED)
|
||||
return
|
||||
case 'busy':
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_BUSY)
|
||||
return
|
||||
default:
|
||||
this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`)
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err)))
|
||||
}
|
||||
this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`)
|
||||
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err)))
|
||||
} finally {
|
||||
this.compacting.delete(sessionId)
|
||||
this.compactAborts.delete(sessionId)
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ export const MSG = {
|
||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
||||
},
|
||||
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
|
||||
COMPACT_UNAVAILABLE: '当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。',
|
||||
COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。',
|
||||
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
|
||||
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
|
||||
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
|
||||
|
||||
Reference in New Issue
Block a user