Files
HomeAgent/internal/agent/core/stop_semantics_test.go
JianFeeeee ccc2ac2d4d 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 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00

191 lines
5.9 KiB
Go
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.

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
}