返回 DeepSeek-Reasonix
render_test.go
根目录 / internal / bot / render_test.go
1 package bot
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/event"
14 )
15
16 func TestApprovalCardCarriesChatType(t *testing.T) {
17 if got := renderApprovalText(event.Approval{
18 ID: "r1", Tool: "write_file", Subject: "a.go", Kind: "recovery",
19 Recovery: &event.RecoveryApproval{
20 FailedTool: "bash", FailedSummary: "exit 1", Diagnosis: "nil pointer",
21 NextTool: "write_file", NextAction: "edit a.go", ChangeRationale: "strategy change",
22 SourceAgent: "subagent",
23 },
24 }); !strings.Contains(got, "执行前确认") || !strings.Contains(got, "回复 1 继续,2 换个办法") || strings.Contains(got, "Auto Guard") {
25 t.Fatalf("recovery text = %q", got)
26 }
27
28 grantApproval := event.Approval{ID: "r2", Kind: "recovery", Recovery: &event.RecoveryApproval{
29 CanGrantTask: true, TaskGrantScope: "git push origin → feature",
30 }}
31 if got := renderRecoveryText(grantApproval); !strings.Contains(got, "2 在本任务内允许同类操作") ||
32 !strings.Contains(got, "风险升级仍会再次确认") || !strings.Contains(got, "授权范围: git push origin → feature") {
33 t.Fatalf("task-grant recovery text = %q", got)
34 }
35 keyboard := recoveryKeyboard(grantApproval)
36 if len(keyboard.Rows) != 2 || keyboard.Rows[0].Buttons[1].CallbackID != "/recovery-continue-task r2" {
37 t.Fatalf("task-grant keyboard = %#v", keyboard)
38 }
39 grantCard := recoveryCard(grantApproval, ChatDM, "allowed-user")
40 grantActions, ok := grantCard.Elements[1].Extra["actions"].([]map[string]any)
41 if !ok || len(grantActions) != 3 {
42 t.Fatalf("task-grant card actions = %#v", grantCard.Elements[1].Extra["actions"])
43 }
44
45 planApproval := event.Approval{ID: "r3", Kind: "recovery", Recovery: &event.RecoveryApproval{
46 ChangeKind: "strategy", NextAction: "replace the storage backend", ChangeRationale: "the original approach cannot satisfy the requirement",
47 PlanBefore: "1. Keep the current storage backend", PlanAfter: "1. Replace the storage backend",
48 }}
49 if got := renderRecoveryText(planApproval); !strings.Contains(got, "执行计划需要你的决定") ||
50 !strings.Contains(got, "原计划:\n1. Keep the current storage backend") ||
51 !strings.Contains(got, "新计划:\n1. Replace the storage backend") ||
52 !strings.Contains(got, "回复 1 采用新计划并继续,2 不采用并让 Auto 调整") {
53 t.Fatalf("plan-change recovery text = %q", got)
54 }
55 planKeyboard := recoveryKeyboard(planApproval)
56 if len(planKeyboard.Rows) != 1 || planKeyboard.Rows[0].Buttons[0].Label != "1 采用并继续" || planKeyboard.Rows[0].Buttons[0].Style != 0 {
57 t.Fatalf("plan-change keyboard = %#v", planKeyboard)
58 }
59 planCard := recoveryCard(planApproval, ChatDM, "allowed-user")
60 if planCard.Header != "执行计划需要你的决定" {
61 t.Fatalf("plan-change card header = %q", planCard.Header)
62 }
63
64 card := approvalCard(event.Approval{ID: "approval-1", Tool: "bash", Subject: "ls"}, ChatDM, "allowed-user")
65 if len(card.Elements) < 2 {
66 t.Fatalf("approval card elements = %d, want at least 2", len(card.Elements))
67 }
68 actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
69 if !ok || len(actions) == 0 {
70 t.Fatalf("approval card actions missing or wrong type: %#v", card.Elements[1].Extra["actions"])
71 }
72 value, ok := actions[0]["value"].(map[string]string)
73 if !ok {
74 t.Fatalf("approval action value has wrong type: %#v", actions[0]["value"])
75 }
76 if value["command"] != "/approve approval-1" {
77 t.Fatalf("command = %q, want /approve approval-1", value["command"])
78 }
79 if value["chat_type"] != string(ChatDM) {
80 t.Fatalf("chat_type = %q, want %q", value["chat_type"], ChatDM)
81 }
82 if value["user_id"] != "allowed-user" {
83 t.Fatalf("user_id = %q, want allowed-user", value["user_id"])
84 }
85 }
86
87 func TestApprovalCardActionsAreToolAgnostic(t *testing.T) {
88 for _, approval := range []event.Approval{
89 {ID: "plan-1", Tool: "exit_plan_mode", Subject: "plan"},
90 {ID: "task-1", Tool: "task", Subject: "run subtask"},
91 } {
92 card := approvalCard(approval, ChatGroup, "allowed-user")
93 if len(card.Elements) < 2 {
94 t.Fatalf("%s card elements = %d, want actions", approval.Tool, len(card.Elements))
95 }
96 actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
97 if !ok || len(actions) != 2 {
98 t.Fatalf("%s actions missing or wrong type: %#v", approval.Tool, card.Elements[1].Extra["actions"])
99 }
100 allow, ok := actions[0]["value"].(map[string]string)
101 if !ok {
102 t.Fatalf("%s allow value has wrong type: %#v", approval.Tool, actions[0]["value"])
103 }
104 deny, ok := actions[1]["value"].(map[string]string)
105 if !ok {
106 t.Fatalf("%s deny value has wrong type: %#v", approval.Tool, actions[1]["value"])
107 }
108 if allow["command"] != "/approve "+approval.ID || deny["command"] != "/deny "+approval.ID {
109 t.Fatalf("%s commands = %q/%q, want approve/deny by id", approval.Tool, allow["command"], deny["command"])
110 }
111 }
112 }
113
114 func TestAskCardAddsAnswerButtonsForSingleChoice(t *testing.T) {
115 card := askCard(event.Ask{
116 ID: "ask-1",
117 Questions: []event.AskQuestion{{
118 ID: "q1",
119 Prompt: "Choose one",
120 Options: []event.AskOption{
121 {Label: "允许一次"},
122 {Label: "拒绝"},
123 },
124 }},
125 }, "fallback", ChatDM, "allowed-user")
126
127 if len(card.Elements) != 2 {
128 t.Fatalf("ask card elements = %d, want markdown + actions", len(card.Elements))
129 }
130 actions, ok := card.Elements[1].Extra["actions"].([]map[string]any)
131 if !ok || len(actions) != 2 {
132 t.Fatalf("ask card actions missing or wrong type: %#v", card.Elements[1].Extra["actions"])
133 }
134 value, ok := actions[0]["value"].(map[string]string)
135 if !ok {
136 t.Fatalf("ask action value has wrong type: %#v", actions[0]["value"])
137 }
138 if value["command"] != "/answer ask-1 1" {
139 t.Fatalf("command = %q, want /answer ask-1 1", value["command"])
140 }
141 if value["chat_type"] != string(ChatDM) {
142 t.Fatalf("chat_type = %q, want %q", value["chat_type"], ChatDM)
143 }
144 if value["user_id"] != "allowed-user" {
145 t.Fatalf("user_id = %q, want allowed-user", value["user_id"])
146 }
147 }
148
149 func TestRenderSinkDoesNotFlushMidSentenceOnTimer(t *testing.T) {
150 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
151 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
152 sink.lastFlush = time.Now().Add(-2 * time.Second)
153
154 sink.Emit(event.Event{Kind: event.Text, Text: "我是 **"})
155 sink.Emit(event.Event{Kind: event.Text, Text: "Reasonix**,一个专注于执行代码任务的 AI 编程助手"})
156
157 if sent := adapter.sentMessages(); len(sent) != 0 {
158 t.Fatalf("sent = %+v, want no mid-sentence flush", sent)
159 }
160
161 sink.Emit(event.Event{Kind: event.TurnDone})
162 sent := adapter.sentMessages()
163 if len(sent) != 1 {
164 t.Fatalf("sent count = %d, want final flush only", len(sent))
165 }
166 if sent[0].Text != "我是 **Reasonix**,一个专注于执行代码任务的 AI 编程助手" {
167 t.Fatalf("sent text = %q, want combined sentence", sent[0].Text)
168 }
169 }
170
171 func TestRenderSinkKeepsSemanticTextUntilFinalResult(t *testing.T) {
172 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
173 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
174 sink.lastFlush = time.Now().Add(-2 * time.Second)
175
176 sink.Emit(event.Event{Kind: event.Text, Text: "第一句。"})
177
178 if sent := adapter.sentMessages(); len(sent) != 0 {
179 t.Fatalf("sent = %+v, want semantic text held until final result", sent)
180 }
181
182 sink.Emit(event.Event{Kind: event.TurnDone})
183 sent := adapter.sentMessages()
184 if len(sent) != 1 {
185 t.Fatalf("sent count = %d, want final result only", len(sent))
186 }
187 if sent[0].Text != "第一句。" {
188 t.Fatalf("sent text = %q, want final result", sent[0].Text)
189 }
190 }
191
192 func TestRenderSinkFinalFlushKeepsChunkLimit(t *testing.T) {
193 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
194 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
195 sink.buf.WriteString(strings.Repeat("长", renderMaxChunkRunes*2+10))
196
197 sink.Emit(event.Event{Kind: event.TurnDone})
198
199 sent := adapter.sentMessages()
200 if len(sent) < 2 {
201 t.Fatalf("sent count = %d, want chunked final flush", len(sent))
202 }
203 for i, msg := range sent {
204 if got := len([]rune(msg.Text)); got > renderMaxChunkRunes {
205 t.Fatalf("sent[%d] runes = %d, want <= %d", i, got, renderMaxChunkRunes)
206 }
207 }
208 }
209
210 func TestRenderSinkConsumesEmptyWhitespacePrefix(t *testing.T) {
211 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
212 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
213 sink.buf.WriteString("\n工具状态")
214
215 sink.flushPrefix(1)
216
217 if got := sink.buf.String(); got != "工具状态" {
218 t.Fatalf("buffer = %q, want leading newline consumed", got)
219 }
220 if sent := adapter.sentMessages(); len(sent) != 0 {
221 t.Fatalf("sent = %+v, want no empty outbound message", sent)
222 }
223 }
224
225 func TestRenderSinkSendsProgressWithoutToolOutput(t *testing.T) {
226 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
227 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
228
229 sink.Emit(event.Event{Kind: event.TurnStarted})
230 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool-1", Name: "read_file", ReadOnly: true}})
231 sink.lastProgress = time.Now().Add(-renderProgressMinInterval)
232 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool-1", Name: "read_file", ReadOnly: true, Refreshed: true}})
233 sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "tool-1", Name: "read_file", Output: "secret output that should stay out of IM"}})
234 sink.Emit(event.Event{Kind: event.Text, Text: "完成。"})
235 sink.Emit(event.Event{Kind: event.TurnDone})
236
237 sent := adapter.sentMessages()
238 if len(sent) != 2 {
239 t.Fatalf("sent count = %d, want one progress message plus final result: %+v", len(sent), sent)
240 }
241 if sent[0].Text != "正在执行: read_file" {
242 t.Fatalf("progress text = %q, want concise tool status", sent[0].Text)
243 }
244 if strings.Contains(sent[0].Text, "secret output") || strings.Contains(sent[1].Text, "secret output") {
245 t.Fatalf("tool output leaked into IM messages: %+v", sent)
246 }
247 if sent[1].Text != "完成。" {
248 t.Fatalf("final text = %q, want final result only", sent[1].Text)
249 }
250 }
251
252 func TestRenderSinkLimitsProgressMessages(t *testing.T) {
253 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
254 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
255
256 for i := 0; i < renderMaxProgressMessages+2; i++ {
257 sink.lastProgress = time.Now().Add(-renderProgressMinInterval)
258 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "tool", Name: "bash"}})
259 }
260
261 sent := adapter.sentMessages()
262 if len(sent) != renderMaxProgressMessages {
263 t.Fatalf("sent count = %d, want capped progress count %d", len(sent), renderMaxProgressMessages)
264 }
265 }
266
267 type fakeEditorAdapter struct {
268 *fakeAdapter
269 mu sync.Mutex
270 edits []editRecord
271 editErr error
272 }
273
274 type editRecord struct {
275 messageID string
276 text string
277 }
278
279 func newFakeEditorAdapter() *fakeEditorAdapter {
280 return &fakeEditorAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
281 }
282
283 func (f *fakeEditorAdapter) EditMessage(ctx context.Context, messageID string, msg OutboundMessage) error {
284 f.mu.Lock()
285 defer f.mu.Unlock()
286 if f.editErr != nil {
287 return f.editErr
288 }
289 f.edits = append(f.edits, editRecord{messageID: messageID, text: msg.Text})
290 return nil
291 }
292
293 func (f *fakeEditorAdapter) editRecords() []editRecord {
294 f.mu.Lock()
295 defer f.mu.Unlock()
296 out := make([]editRecord, len(f.edits))
297 copy(out, f.edits)
298 return out
299 }
300
301 func TestRenderSinkStreamsIntoLiveMessage(t *testing.T) {
302 adapter := newFakeEditorAdapter()
303 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
304
305 // 第一个增量:超过软窗口后创建 live 消息。
306 sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
307 sink.Emit(event.Event{Kind: event.Text, Text: "第一段内容"})
308 if sent := adapter.sentMessages(); len(sent) != 1 || sent[0].Text != "第一段内容" {
309 t.Fatalf("sent = %+v, want live message created with first chunk", sent)
310 }
311 if sink.liveMsgID != "fake_msg_1" {
312 t.Fatalf("liveMsgID = %q, want fake_msg_1", sink.liveMsgID)
313 }
314
315 // 第二个增量:原地编辑同一条消息,而不是再发一条。
316 sink.lastEdit = time.Now().Add(-2 * renderSoftFlushAfter)
317 sink.Emit(event.Event{Kind: event.Text, Text: ",第二段内容。"})
318 if sent := adapter.sentMessages(); len(sent) != 1 {
319 t.Fatalf("sent count = %d, want still one message after streaming edit", len(sent))
320 }
321 edits := adapter.editRecords()
322 if len(edits) != 1 || edits[0].messageID != "fake_msg_1" {
323 t.Fatalf("edits = %+v, want one edit to live message", edits)
324 }
325 if edits[0].text != "第一段内容,第二段内容。" {
326 t.Fatalf("edit text = %q, want cumulative content", edits[0].text)
327 }
328
329 // 回合结束:最终内容编辑进 live 消息,不再新发。
330 sink.Emit(event.Event{Kind: event.Text, Text: "收尾。"})
331 sink.Emit(event.Event{Kind: event.TurnDone})
332 if sent := adapter.sentMessages(); len(sent) != 1 {
333 t.Fatalf("sent count = %d, want no extra message at turn end", len(sent))
334 }
335 edits = adapter.editRecords()
336 final := edits[len(edits)-1]
337 if final.text != "第一段内容,第二段内容。收尾。" {
338 t.Fatalf("final edit = %q, want full content", final.text)
339 }
340 if sink.liveMsgID != "" {
341 t.Fatalf("liveMsgID = %q, want cleared after turn done", sink.liveMsgID)
342 }
343 }
344
345 func TestRenderSinkStreamingThrottledBySoftWindow(t *testing.T) {
346 adapter := newFakeEditorAdapter()
347 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
348
349 // 软窗口内的增量不触发任何网络调用。
350 sink.Emit(event.Event{Kind: event.Text, Text: "刚开始的内容"})
351 if sent := adapter.sentMessages(); len(sent) != 0 {
352 t.Fatalf("sent = %+v, want throttled inside soft window", sent)
353 }
354 if edits := adapter.editRecords(); len(edits) != 0 {
355 t.Fatalf("edits = %+v, want none inside soft window", edits)
356 }
357 }
358
359 func TestRenderSinkStreamingEditFailureRotatesWithoutDuplication(t *testing.T) {
360 adapter := newFakeEditorAdapter()
361 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
362
363 sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
364 sink.Emit(event.Event{Kind: event.Text, Text: "已送达的内容。"})
365 if sink.liveMsgID == "" {
366 t.Fatal("live message should be created")
367 }
368
369 // 编辑失败:块轮转,已送达前缀不重发。
370 adapter.mu.Lock()
371 adapter.editErr = context.DeadlineExceeded
372 adapter.mu.Unlock()
373 sink.lastEdit = time.Now().Add(-2 * renderSoftFlushAfter)
374 sink.Emit(event.Event{Kind: event.Text, Text: "后续内容。"})
375 if sink.liveMsgID != "" {
376 t.Fatalf("liveMsgID = %q, want rotation after edit failure", sink.liveMsgID)
377 }
378
379 adapter.mu.Lock()
380 adapter.editErr = nil
381 adapter.mu.Unlock()
382 sink.Emit(event.Event{Kind: event.TurnDone})
383 sent := adapter.sentMessages()
384 if len(sent) != 2 {
385 t.Fatalf("sent count = %d, want live message plus rotated tail: %+v", len(sent), sent)
386 }
387 if sent[1].Text != "后续内容。" {
388 t.Fatalf("rotated tail = %q, want only undelivered content", sent[1].Text)
389 }
390 }
391
392 func TestRenderSinkStreamingHardCapRotatesBlocks(t *testing.T) {
393 adapter := newFakeEditorAdapter()
394 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
395
396 sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
397 sink.Emit(event.Event{Kind: event.Text, Text: "第一句。"})
398 if sink.liveMsgID == "" {
399 t.Fatal("live message should be created")
400 }
401
402 // 超过硬上限:live 消息按语义边界收尾,剩余进入下一块。
403 sink.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("长", renderHardChunkRunes) + "。尾部"})
404 if sink.liveMsgID != "" {
405 t.Fatalf("liveMsgID = %q, want block closed at hard cap", sink.liveMsgID)
406 }
407 edits := adapter.editRecords()
408 if len(edits) == 0 {
409 t.Fatal("hard cap should finalize the live message via edit")
410 }
411 if got := len([]rune(edits[len(edits)-1].text)); got > renderMaxChunkRunes {
412 t.Fatalf("finalized block runes = %d, want <= %d", got, renderMaxChunkRunes)
413 }
414
415 sink.Emit(event.Event{Kind: event.TurnDone})
416 sent := adapter.sentMessages()
417 if len(sent) < 2 {
418 t.Fatalf("sent count = %d, want new message for the next block", len(sent))
419 }
420 }
421
422 // failingEditorAdapter accepts the initial Send (returns a message id so
423 // streaming engages) but fails every edit, simulating a rate-limited / recalled
424 // live message mid-turn.
425 type failingEditorAdapter struct {
426 *fakeAdapter
427 }
428
429 func (f *failingEditorAdapter) EditMessage(ctx context.Context, id string, msg OutboundMessage) error {
430 return fmt.Errorf("simulated edit failure")
431 }
432
433 func TestRenderSinkStreamingEditFailureDoesNotDuplicate(t *testing.T) {
434 adapter := &failingEditorAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
435 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
436
437 // Stream a first chunk so a live message is created (liveSentBytes == full).
438 sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
439 head := strings.Repeat("a", 2000)
440 sink.Emit(event.Event{Kind: event.Text, Text: head})
441 if sink.liveMsgID == "" {
442 t.Fatalf("streaming did not engage; sent=%d", len(adapter.sentMessages()))
443 }
444 // Push past the hard cap so flushPrefix runs with idx < liveSentBytes, then
445 // finish. Every edit fails, so rotation must not re-queue already-shown text.
446 tail := strings.Repeat("b", renderHardChunkRunes)
447 sink.Emit(event.Event{Kind: event.Text, Text: tail})
448 sink.Emit(event.Event{Kind: event.TurnDone})
449
450 var shown strings.Builder
451 shown.WriteString(head) // live message frozen at last successful state
452 for _, m := range adapter.sentMessages()[1:] {
453 shown.WriteString(m.Text)
454 }
455 wantRunes := len([]rune(head + tail))
456 if gotRunes := len([]rune(shown.String())); gotRunes > wantRunes {
457 t.Fatalf("duplication: user would see %d runes, expected at most %d (%d duplicated)", gotRunes, wantRunes, gotRunes-wantRunes)
458 }
459 }
460
461 func TestRenderSinkStreamingFinalizesInOneEditWithoutSplit(t *testing.T) {
462 adapter := newFakeEditorAdapter()
463 sink := newRenderSink(context.Background(), adapter, "feishu-feishu", "feishu", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
464
465 // A final answer that does NOT end on a semantic boundary (ends inside a
466 // code fence) must be finalized as a single in-place edit, not shrunk +
467 // tail-as-new-message.
468 sink.lastFlush = time.Now().Add(-2 * renderSoftFlushAfter)
469 sink.Emit(event.Event{Kind: event.Text, Text: "结论如下。这里是代码:\n```go\nfmt.Println(\"x\")\n```"})
470 sink.Emit(event.Event{Kind: event.TurnDone})
471
472 if sent := adapter.sentMessages(); len(sent) != 1 {
473 t.Fatalf("sent %d messages, want exactly one live message (no split): %+v", len(sent), sent)
474 }
475 edits := adapter.editRecords()
476 if len(edits) == 0 || !strings.Contains(edits[len(edits)-1].text, "```") {
477 t.Fatalf("final edit should carry the full text including the code fence: %+v", edits)
478 }
479 }
480
481 func TestRenderSinkSuppressesReasoning(t *testing.T) {
482 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
483 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
484
485 sink.Emit(event.Event{Kind: event.Reasoning, Text: "internal reasoning"})
486 sink.Emit(event.Event{Kind: event.Text, Text: "可见结果"})
487 sink.Emit(event.Event{Kind: event.TurnDone})
488
489 sent := adapter.sentMessages()
490 if len(sent) != 1 {
491 t.Fatalf("sent count = %d, want one final result", len(sent))
492 }
493 if strings.Contains(sent[0].Text, "internal reasoning") {
494 t.Fatalf("reasoning leaked into IM message: %q", sent[0].Text)
495 }
496 }
497
498 func TestRenderSinkSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
499 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
500 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
501
502 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "please resend your message"})
503 for _, code := range []string{
504 event.NoticeCodeSessionRecoveryForked,
505 event.NoticeCodeSessionRecoveryAdopted,
506 event.NoticeCodeSessionRecoveryAdoptedCovered,
507 event.NoticeCodeSessionRecoveryDepthCap,
508 event.NoticeCodeSessionShutdownRecoveryForked,
509 } {
510 sink.Emit(event.Event{
511 Kind: event.Notice, Level: event.LevelWarn,
512 Audience: event.NoticeAudienceOperator,
513 Code: code,
514 Text: "local session maintenance",
515 })
516 }
517
518 sent := adapter.sentMessages()
519 if len(sent) != 1 {
520 t.Fatalf("sent = %+v, want only the actionable user warning", sent)
521 }
522 if sent[0].Text != "⚠️ please resend your message" {
523 t.Fatalf("sent text = %q, want the ordinary user warning", sent[0].Text)
524 }
525 }
526
527 // TestRenderSinkIgnoresSubagentProgress locks the bot policy for the reserved
528 // sub-agent progress ToolProgress channels: streaming previews must never leak
529 // into IM channels, exactly like ordinary tool output.
530 func TestRenderSinkIgnoresSubagentProgress(t *testing.T) {
531 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
532 sink := newRenderSink(context.Background(), adapter, "weixin-weixin", "weixin", "chat-1", ChatDM, "user-1", "msg-1", slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
533
534 sink.Emit(event.Event{Kind: event.TurnStarted})
535 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task"}})
536 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
537 ID: "task-1", Name: event.SubagentProgressStatusName, Output: "reasoning",
538 }})
539 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
540 ID: "task-1", Name: event.SubagentProgressReasoningName, Output: "thinking out loud",
541 }})
542 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
543 ID: "task-1", Name: event.SubagentProgressTextName, Output: "answer preview",
544 }})
545 sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "task-1", Name: "task", Output: "final"}})
546 sink.Emit(event.Event{Kind: event.TurnDone})
547
548 sent := adapter.sentMessages()
549 if len(sent) != 1 {
550 t.Fatalf("sent count = %d, want only the dispatch status: %+v", len(sent), sent)
551 }
552 for i, m := range sent {
553 if strings.Contains(m.Text, "thinking out loud") || strings.Contains(m.Text, "answer preview") {
554 t.Fatalf("sub-agent preview leaked into IM message %d: %+v", i, m)
555 }
556 }
557 }
558
558 lines GO