From ccc2ac2d4d47af0b3a9b8d76e6bf08f7e813e387 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 11:26:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(stop):=20=E5=81=9C=E6=AD=A2=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E7=9C=9F=E6=AD=A3=E7=94=9F=E6=95=88=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=20=E2=89=A0=20=E7=A9=BA=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=EF=BC=9B=E9=B8=BF=E8=92=99=20screensue=20=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。 ## 症状(实测取证) 1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200 `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。 2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。 ## 根因 停止按钮走的是「空内容中断」,而 interceptLoop 有一行 `if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。 所以停止指令从未到达调度器;接口那个 200 是不诚实的。 另查明两条会放大症状的既有问题(停止后仍在跑): - `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。 上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。 - `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。 这是给「被更高中断抢占」用的(现场要交出去、稍后继续), 但用户按停止是「不要了」,重跑就是停止没生效。 ## 修法(按用户明确的设计) 停止 = ①立即结束当前 LLM 推理(不重试、不恢复); ②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。 - scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/ `consumeCancel`。配额取快照值(停止后新到的输入不受影响), 重复按停止取 max 不累加(两个客户端同时按不该翻倍)。 - `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM; **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。 带注释的停止(`/stop 换个话题`)仍走中断路径。 - `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。 - `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。 - `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是 Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退, 否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。 - WebUI handler / CLI `/stop`:空消息时带 `stop:true`。 ## 鸿蒙端 - `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名, 避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。 - `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络), 纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容, Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。 注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。 - `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明, ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。 ## 验证 - 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、 配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。 - `go test ./internal/... ./cmd/...` 全绿。 - 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受 READ_PASTEBOARD 授权限制装不上,与既有记录一致)。 --- .../entry/src/main/ets/common/BridgeCaps.ets | 69 +++++++ .../entry/src/main/ets/common/ChatSession.ets | 38 +++- .../src/main/ets/components/ScreensuePage.ets | 30 ++- internal/agent/core/eventloop.go | 25 ++- internal/agent/core/process.go | 21 +- internal/agent/core/scheduler.go | 57 ++++++ internal/agent/core/stop_semantics_test.go | 190 ++++++++++++++++++ internal/agent/core/task.go | 20 ++ internal/plugins/cli/plugin.go | 10 +- internal/plugins/webui/handler_upload.go | 26 ++- 10 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 internal/agent/core/stop_semantics_test.go diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets index 6c895f2..419074a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets @@ -452,3 +452,72 @@ export function parseScreensue(rawArgs: string): ScreensuePayload { p.content = rest; return p; } + +/** + * 判断 agent 下发的 screensue 内容是不是 HTML 片段。 + * + * 服务端协议两侧都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m + * 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。此前鸿蒙端一律 + * 塞进 Text(),于是 HTML 被当成普通字符串原样显示成标签。 + * + * 判据取「首个非空字符是 '<'」并且「存在配对的 '>'」且含字母/斜杠, + * 避免把 "<3" 这类纯文本误判成标签;再排除纯文本里常见的比较式(如 "a < b")。 + */ +export function looksLikeHtml(content: string): boolean { + const trimmed: string = content.trim(); + if (trimmed.length === 0 || trimmed.charAt(0) !== '<') { + return false; + } + const close: number = trimmed.indexOf('>'); + if (close < 0) { + return false; + } + // 标签名必须是字母开头(、

),"< 3" 这类不是标签。 + const inner: string = trimmed.substring(1, close).trim(); + if (inner.length === 0) { + return false; + } + const head: string = inner.charAt(0) === '/' ? inner.substring(1) : inner; + const tagName: RegExp = new RegExp('^[a-zA-Z][a-zA-Z0-9]*'); + return tagName.test(head); +} + +/** + * 把一段内容包成可交给 RichText 渲染的最小 HTML 文档。 + * + * 为什么用 RichText 而不是 Web:screensue 的内容是 agent 下发的第三方文本, + * Web 组件默认带 JS 与网络能力(javaScriptAccess/fileAccess),等于让远端内容 + * 在客户端进程里执行脚本。RichText 只解析 HTML 子集、无脚本、无网络, + * 对「给用户看一段内容」这个用途正好够用且更安全。 + * + * RichText 用系统默认前景色,深色主题下会变成黑字看不见 —— 这里显式注入 + * 颜色/字号,让两种主题下都可读。 + */ +export function screensueHtml(content: string, dark: boolean): string { + const fg: string = dark ? '#E8ECF4' : '#1B2430'; + const trimmed: string = content.trim(); + // 已是完整文档就不要重复包 (RichText 需要单一根节点才稳定)。 + const body: string = looksLikeHtml(trimmed) ? trimmed : escapeHtmlText(trimmed); + return '
' + body + '
'; +} + +/** HTML 转义(纯文本走 RichText 时用,避免内容里的 < > 被当成标签)。 */ +export function escapeHtmlText(s: string): string { + let out: string = ''; + for (let i = 0; i < s.length; i++) { + const c: string = s.charAt(i); + if (c === '&') { + out += '&'; + } else if (c === '<') { + out += '<'; + } else if (c === '>') { + out += '>'; + } else if (c === '"') { + out += '"'; + } else { + out += c; + } + } + return out; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets index 0fb5122..9a320b1 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets @@ -21,6 +21,14 @@ interface SendChatBody { device_name?: string; } +/** POST /chat/interrupt 的请求体。 */ +interface InterruptBody { + /** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */ + stop: boolean; + /** 可选:中断时附带给模型的一句话;停止时为 undefined。 */ + message?: string; +} + /** * 纯文本发送:POST /chat。 * 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。 @@ -167,10 +175,32 @@ export async function sendChatFile(text: string, path: string, name: string, chatStore.requestScroll(); } +/** + * 停止当前生成(停止按钮)。 + * + * 发送 **`stop: true`**,与「带一句话的中断」区分开: + * - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试); + * ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。 + * - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。 + * + * 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成 + * “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。 + * 带中文字段比空 body 多不了几个字节,就把语义说清楚了。 + */ export async function interruptChat(): Promise { - try { - await apiClient.post('/chat/interrupt', null); - } catch (e) { - // ignore + if (connStore.getCurrentConnection() === null) { + return; } + try { + const body: InterruptBody = { stop: true }; + await apiClient.post('/chat/interrupt', body); + } catch (e) { + // 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位, + // 否则按钮会一直停在“停止”态,用户以为没生效。 + } + // 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。 + // 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。 + chatStore.setLoading(false); + chatStore.setStage('已停止'); + chatStore.forceRefresh(); } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets index 3c1e7a6..45f9d9b 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets @@ -2,12 +2,17 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo import { GradientBackground } from './GradientBackground'; import { PageTopBar } from './PageTopBar'; import { MotionBase } from './MotionBase'; +import { looksLikeHtml, screensueHtml } from '../common/BridgeCaps'; /** * agent 主动推送的前台内容页。 * * 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏, * 从而让左侧一级页面和主导航保持可见、可操作。 + * + * 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 BridgeCaps 的 + * screensueHtml)。HTML 用 RichText 渲染:它只解析 HTML 子集、无脚本、无网络, + * 对 agent 下发的远端内容比 Web 组件安全(Web 默认带 JS 与网络能力)。 */ @Component export struct ScreensuePage { @@ -17,6 +22,11 @@ export struct ScreensuePage { onClose: () => void = () => { }; + /** 本轮内容是不是 HTML(决定走 RichText 还是 Text)。 */ + private htmlMode(): boolean { + return looksLikeHtml(this.pushedText); + } + build() { Stack({ alignContent: Alignment.Bottom }) { GradientBackground() @@ -44,13 +54,19 @@ export struct ScreensuePage { .width('100%') Column() { - Text(this.pushedText) - .fontSize(16) - .lineHeight(25) - .fontColor(this.palette().textPrimary) - .width('100%') - .textAlign(TextAlign.Start) - .copyOption(CopyOptions.LocalDevice) + if (this.htmlMode()) { + // HTML:RichText 自己排版,外层用带主题背景的卡片承载。 + RichText(screensueHtml(this.pushedText, this.isDark)) + .width('100%') + } else { + Text(this.pushedText) + .fontSize(16) + .lineHeight(25) + .fontColor(this.palette().textPrimary) + .width('100%') + .textAlign(TextAlign.Start) + .copyOption(CopyOptions.LocalDevice) + } } .width('100%') .padding(18) diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index e70a084..7e2fe79 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -31,9 +31,32 @@ func (a *Agent) interceptLoop() { select { case evt := <-a.io.InputInterruptChan(): text, _ := evt.Payload["content"].(string) - if text == "" { + stop, _ := evt.Payload["stop"].(bool) + if text == "" && !stop { + // 没有内容也不是停止指令:没有可处理的东西(旧行为)。 + // + // 注意:**不能**把“空内容”一律当成空操作。客户端停止按钮 + // 本来就不带消息(/chat/interrupt 收 body 空的 {}), + // 旧代码在这里 continue 掉,于是停止按钮毫无反应, + // 而且接口还回 200 骗调用方——已实测:HTTP 200 但内核零日志、 + // 生成继续跑到自然结束。 continue } + if stop { + // 停止:①立即结束当前 LLM 推理;②登记短路配额。 + if n := a.sched.armStop(); n > 0 || a.sched.takeStop() { + log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)", + evt.Source, evt.OutputChannel, n) + } + a.cancelCurrentLLM() + if text == "" { + // 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条 + // 中断入队,取消后会以空内容重跑一轮,停下之后又“活着”。 + continue + } + // 带注释的停止(/stop 说句话):注释本身仍作为中断处理, + // 走下面的正常路径——用户想看模型对被停下话题的回应。 + } log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80)) clone := &agentIO.InputEvent{ diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index fe6d22b..e507d08 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -135,6 +135,20 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message { func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) { ch, err := p.ChatStream(ctx, req) if err != nil { + // 上下文已取消/超时:**绝不能**回退到非流式 Chat。 + // + // 回退意味着再发一次完整请求,而这时用户已经按了停止(或请求已超时), + // 结果是“按了停止又跑了一遍”——停止按钮看起来毫无反应的一个真实成因。 + // + // 判据取 **ctx.Err()** 而不是“错误是不是 context.Canceled”:很多 provider + // 不支持流式时也回 Canceled 表示“请走非流式”(仓里大量假 provider 即如此), + // 那种情况必须继续回退,否则会把“不支持流式”误当成“已被取消”。 + if ctx.Err() != nil { + if err == nil { + err = ctx.Err() + } + return nil, err + } log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err) return p.Chat(ctx, req) } @@ -161,11 +175,16 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent return resp, nil } - // 其他错误(网络中断等):已累积到实质内容则返回部分结果,否则回退非流式 + // 其他错误(网络中断等):已累积到实质内容则返回部分结果,否则回退非流式。 if resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { log.Printf("[agent] stream interrupted mid-way (%v), returning partial result", accErr) return resp, nil } + // 取消类错误不能回退(否则等于再跑一遍完整的非流式请求)。 + // 同样以 ctx.Err() 为准:真取消才拦,provider 探活不算。 + if ctx.Err() != nil { + return resp, accErr + } log.Printf("[agent] stream failed before content (%v), falling back to non-stream chat", accErr) return p.Chat(ctx, req) } diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index 330aaca..fe34122 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -364,6 +364,16 @@ type scheduler struct { // 被挂起 4 帧;L4 之上没有更高级别,链到此为止。超限只可能是内核 bug, // 因此这里只做防御性计数,**不降级、不丢弃帧**。 maxInterruptFrames int + + // cancelBudget 是「停止」剩下的短路配额(见 Agent.RequestStop)。 + // + // 语义(用户明确的设计):停止 = ①立即结束当前 LLM 推理;②对**停止那一刻 + // 已排队**的 x 条消息,后续依次在 pre-action 阶段短路,而不是把它们当 + // 中断/新输入再跑一遍。配额是快照值:停止之后**新到**的输入不受影响。 + cancelBudget int + // stopArmed 标记“下一条待收尾的任务是因为用户按了停止”。 + // 取消 LLM 后 stepLLM 默认重跑本步;用户停止时必须改为直接收尾。 + stopArmed bool } // suspendedTask 是一个被抢占任务的现场。 @@ -573,6 +583,53 @@ func (s *scheduler) setImmediateLocked(t *Task) bool { return true } +// armStop 处理一次「停止」指令:登记短路配额并返回**停止那一刻的排队深度**。 +// +// 语义(用户明确的设计):停止 = ①立即结束当前 LLM 推理;②对停止那一刻 +// 已排队的 x 条消息,后续依次在 pre-action 阶段短路。配额是**快照值**: +// 停止之后新到的输入不受影响(否则停止会变成一个永远生效的“黑洞”)。 +// +// 多次按停止取**较大值**而不是累加:两个客户端同时按下时配额不应翻倍。 +func (s *scheduler) armStop() int { + s.mu.Lock() + defer s.mu.Unlock() + if n := s.pendingQueueLenLocked(); n > s.cancelBudget { + s.cancelBudget = n + } + s.stopArmed = true + return s.cancelBudget +} + +// pendingQueueLenLocked 统计**尚未执行**的排队输入数量(不含中断队列/挂起)。 +func (s *scheduler) pendingQueueLenLocked() int { + return len(s.queue) +} + +// takeStop 消费「当前任务应被立即结束而不是重试」这一次标记。 +// +// 取消 LLM 后 stepLLM 会看到 context.Canceled 并 outcomeContinue 重跑; +// 若这是用户按下的停止,重跑就是错的——应该直接收尾。 +func (s *scheduler) takeStop() bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.stopArmed { + return false + } + s.stopArmed = false + return true +} + +// consumeCancel 消费一格短路配额;true 表示本任务在 pre-action 阶段直接收尾。 +func (s *scheduler) consumeCancel() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.cancelBudget <= 0 { + return false + } + s.cancelBudget-- + return true +} + func removeTask(list []*Task, target *Task) []*Task { for i, t := range list { if t == target { diff --git a/internal/agent/core/stop_semantics_test.go b/internal/agent/core/stop_semantics_test.go new file mode 100644 index 0000000..2b5495e --- /dev/null +++ b/internal/agent/core/stop_semantics_test.go @@ -0,0 +1,190 @@ +package core + +// 「停止」语义的验收测试(用户明确的设计): +// +// 停止 = ①立即结束当前 LLM 推理(不重试、不恢复);②对停止那一刻**已排队**的 +// x 条消息,后续依次在 pre-action 阶段短路,而不是把它们当新输入再跑一遍。 +// +// 为什么单独一组用例:此前的实现把「停止」当成一条普通中断——空内容还会被 +// interceptLoop 直接丢掉。实测症状是接口回 200、内核零日志、生成继续跑到自然结束。 +// 这组用例把三条不变量钉住:停止必须终结当前任务、必须按快照配额短路排队消息、 +// 停止之后新到的输入不受影响。 + +import ( + "context" + "sync" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// stopProvider 是真会走流式路径的 provider:首个 ChatStream 阻塞到 ctx 取消, +// 之后(若真的重跑了)计数会在 callCount 上暴露出来。 +// +// 与 preemptProvider 的区别:后者 ChatStream 恒回错误,会让 +// chatStreamWithFallback 走非流式回退——那条路径恰好是“停止后重跑”的现场, +// 但也让测试看不到流式取消本身的行为。两个 double 各测一段。 +type stopProvider struct { + mu sync.Mutex + calls int + streams int + entered chan struct{} + enteredOn sync.Once +} + +func newStopProvider() *stopProvider { + return &stopProvider{entered: make(chan struct{})} +} + +func (p *stopProvider) Name() string { return "stop" } +func (p *stopProvider) MaxContextTokens() int { return 8192 } + +func (p *stopProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + p.mu.Lock() + p.calls++ + p.mu.Unlock() + return &agentAPI.CompletionResponse{Content: "非流式回退内容"}, nil +} + +func (p *stopProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + p.mu.Lock() + p.calls++ + p.streams++ + p.mu.Unlock() + p.enteredOn.Do(func() { close(p.entered) }) + ch := make(chan agentAPI.StreamChunk) + go func() { + defer close(ch) + <-ctx.Done() + }() + return ch, nil +} + +func (p *stopProvider) callCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.calls +} + +// 停止指令必须终结当前任务,而不是取消后重跑本步。 +func TestStop_TerminatesCurrentTaskWithoutRetry(t *testing.T) { + sp := newStopProvider() + a := newPreemptAgent(t, sp) + + evt, _ := textEvent("webui", "写一篇很长的文章") + task := newInputTask(evt) + if !a.sched.enqueue(task) { + t.Fatal("入队失败") + } + nt, _, _ := a.sched.nextRef() + + done := make(chan struct{}) + var frame *TaskFrame + go func() { + frame, _ = a.runInputTask(nt.Event) + close(done) + }() + + select { + case <-sp.entered: + case <-time.After(3 * time.Second): + t.Fatal("provider 未被调用") + } + + // 与停止按钮完全一致的调用形状:空消息 + stop 标记。 + stopEvt := &agentIO.InputEvent{ + Source: "webui", + Type: "text", + OutputChannel: "webui", + Payload: map[string]interface{}{ + "content": "", "stop": true, "interrupt": true, "priority": "L4", + }, + } + a.io.InjectInterrupt(stopEvt.Source, stopEvt.OutputChannel, stopEvt.Payload) + // interceptLoop 才是 armStop 的调用点;这里模拟它已消费该指令(只 arm, + // 不 takeStop——takeStop 必须由 stepLLM 去消费,那正是被测行为)。 + a.sched.armStop() + a.cancelCurrentLLM() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("停止后任务未结束(仍在重跑或挂起)") + } + + if frame == nil { + t.Fatal("未拿到帧") + } + // 不该出现「取消 → 重跑 → 再问一次模型」:provider 只该被调用一次。 + // 这正是停止按钮失效的核心——实测旧行为:取消后还会(经非流式回退或 + // stepLLM 重跑)再发一次完整请求。 + if n := sp.callCount(); n != 1 { + t.Fatalf("停止后 LLM 调用次数=%d,期望 1(不得重跑/回退)", n) + } + if frame.Response == "" { + t.Fatal("停止应给用户一条收尾说明") + } +} + +// 停止时的排队深度是快照:恰好短路 x 条,之后新到的输入不受影响。 +func TestStop_CancelBudgetIsSnapshot(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + + // 停止那一刻队列里有 2 条。 + a.sched.enqueue(newInputTask(mustTextEvent("qq", "一"))) + a.sched.enqueue(newInputTask(mustTextEvent("qq", "二"))) + n := a.sched.armStop() + if n != 2 { + t.Fatalf("停止时排队深度=%d,期望 2", n) + } + + // 停止后才到的新输入:不受配额影响。 + a.sched.enqueue(newInputTask(mustTextEvent("qq", "三"))) + + canceled := 0 + kept := []string{} + for _, name := range []string{"一", "二", "三"} { + if a.sched.consumeCancel() { + canceled++ + continue + } + kept = append(kept, name) + } + if canceled != 2 { + t.Fatalf("被短路条数=%d,期望 2(快照配额)", canceled) + } + if len(kept) != 1 || kept[0] != "三" { + t.Fatalf("停止后新到的输入应正常执行,实际 kept=%v", kept) + } + + // 配额用尽后不再短路。 + if a.sched.consumeCancel() { + t.Fatal("配额应已耗尽") + } +} + +// 多次按停止取较大值,而不是累加(两个客户端同时按下不该翻倍)。 +func TestStop_ArmTwiceTakesMaxNotSum(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.sched.enqueue(newInputTask(mustTextEvent("qq", "一"))) + a.sched.enqueue(newInputTask(mustTextEvent("qq", "二"))) + if n := a.sched.armStop(); n != 2 { + t.Fatalf("首次 armStop=%d,期望 2", n) + } + if n := a.sched.armStop(); n != 2 { + t.Fatalf("重复 armStop=%d,期望仍为 2(取 max 不累加)", n) + } + if !a.sched.consumeCancel() || !a.sched.consumeCancel() { + t.Fatal("两次配额都应可消费") + } + if a.sched.consumeCancel() { + t.Fatal("第三次不应再有配额") + } +} + +func mustTextEvent(source, text string) *agentIO.InputEvent { + evt, _ := textEvent(source, text) + return evt +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index effeae8..ae975aa 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -536,6 +536,14 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { f.Response = *f.StageCtx.Response return outcomeDone } + // 停止的第二个半边(用户明确的设计):对**停止那一刻已排队**的消息, + // 依次在这里短路——不进 LLM、不执行工具,直接以一条说明收尾。 + // 配额是停止时的快照,消费完即止;停止之后新到的输入不受影响。 + if a.sched.consumeCancel() { + log.Printf("[agent] cancelled queued task at pre-action (input=%q)", truncateStr(f.Input, 40)) + f.Response = "已取消(用户停止)。" + return outcomeDone + } if len(f.StageCtx.ContextMsgs) > 0 { for _, m := range f.StageCtx.ContextMsgs { role, _ := m["role"].(string) @@ -586,6 +594,18 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { f.Err = fmt.Errorf("interrupted by user input") return outcomeFailed } + // 用户按下的「停止」:取消必须**终结本任务**,不是重跑。 + // + // 默认行为(下面的 outcomeContinue 重跑本步)是给「被更高中断抢占」用的: + // 取消只是把现场交出去,稍后还要继续。而停止是用户明确的“不要了”, + // 重跑会让停止按钮看起来没反应(已实测:取消后又发了一次完整请求)。 + if a.sched.takeStop() { + log.Printf("[agent] stop: LLM cancelled, task terminated without retry") + if strings.TrimSpace(f.Response) == "" { + f.Response = "已停止生成。" + } + return outcomeDone + } f.Turn++ return outcomeContinue // 重跑 StepLLM } diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index ea867da..85f35d4 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -393,10 +393,16 @@ func (p *Plugin) cmdInterrupt(conn net.Conn, parts []string, s *sdk.PluginSDK) { } // PriorityL3(交互):/stop 是人在终端上当场下的指令,属于"需要及时处理", // 不该用默认的 L1(后台)——那样它会被排在其它后台注入后面,停得不及时。 - s.InjectInterrupt(cliSource, cliChannel, "text", map[string]interface{}{ + payload := map[string]interface{}{ "content": msg, "priority": sdk.PriorityL3, - }) + } + if msg == "" { + // 无附带消息 = 停止:必须带 stop 标记,否则空内容会被 interceptLoop + // 当空操作丢掉(旧行为:回了"已发送中断信号"但内核什么都没发生)。 + payload["stop"] = true + } + s.InjectInterrupt(cliSource, cliChannel, "text", payload) writeLine(conn, map[string]interface{}{ "type": "response", "content": "已发送中断信号", diff --git a/internal/plugins/webui/handler_upload.go b/internal/plugins/webui/handler_upload.go index d8a00e8..01246ba 100644 --- a/internal/plugins/webui/handler_upload.go +++ b/internal/plugins/webui/handler_upload.go @@ -296,13 +296,15 @@ func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, name, st.ModTime(), f) } -// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。 -// 核心拦截语义(interceptLoop): -// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以 -// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入; -// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。 +// handleChatInterrupt 注入用户中断或「停止」。 // -// message 可选:空则纯取消(仍会注入空内容中断触发取消)。 +// 两种语义分开(用户明确的设计): +// - **停止**(message 为空,即停止按钮):①立即结束当前 LLM 推理(不重试); +// ②对停止那一刻已排队的 x 条消息,后续依次在 pre-action 阶段短路。 +// 只传 stop=true,不入中断队列——旧实现把空停止当普通中断入队,取消后 +// 还会以空内容重跑一轮,表现为“停了又活”。 +// - **中断/补充指令**(message 非空):取消当前流式请求 + 中断入队, +// 模型看到被打断的上下文与用户新输入。 func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -324,12 +326,18 @@ func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) { if body.DeviceID != "" { source = "webui/" + body.DeviceID } - // PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。 + // PriorityL4:终止/停止必须能立即打断当前任务(内核级插件才有的能力)。 // agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效; // 但 LLM 流式段会被立刻取消。 - h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{ + payload := map[string]interface{}{ "content": body.Message, "priority": sdk.PriorityL4, - }) + } + if strings.TrimSpace(body.Message) == "" { + // 空消息 = 停止。必须带 stop 标记,否则空内容会被 interceptLoop + // 当成空操作丢掉(旧行为:接口回 200,实际什么都没发生)。 + payload["stop"] = true + } + h.sdk.InjectInterrupt(source, "webui", "text", payload) writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"}) }