fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML

两处鸿蒙端缺陷 + 一个跨端(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 授权限制装不上,与既有记录一致)。
This commit is contained in:
JianFeeeee
2026-09-18 11:26:12 +08:00
parent 89259b3508
commit f340adb1e1
10 changed files with 462 additions and 24 deletions

View File

@ -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
}