magic-agent 1.0.3 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -242,14 +242,34 @@ func TestCodeBuddyGatewayCompletedEmitsRemainderOnly(t *testing.T) {
|
|
|
242
242
|
srv, _ := cbGatewayTestServer(t, "run-1", frames)
|
|
243
243
|
e := cbGatewayServerEngine(srv)
|
|
244
244
|
|
|
245
|
+
// ⚠️ 必须**按 kind 过滤**收集,不能无脑 append ev.Text:
|
|
246
|
+
// Text 字段在多种事件上都有值(turn_end 带权威全文、tool_use 带 args JSON),
|
|
247
|
+
// 无脑收集在 claude 上本来就会把工具参数混进「正文」。这里顺带把
|
|
248
|
+
// 「正文只来自 KindText」这条不变量也钉住。
|
|
245
249
|
var got []string
|
|
250
|
+
var end *StreamEvent
|
|
246
251
|
if _, err := e.Stream(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}},
|
|
247
|
-
func(ev StreamEvent) {
|
|
252
|
+
func(ev StreamEvent) {
|
|
253
|
+
switch ev.Kind {
|
|
254
|
+
case KindText:
|
|
255
|
+
got = append(got, ev.Text)
|
|
256
|
+
case KindTurnEnd:
|
|
257
|
+
ev := ev
|
|
258
|
+
end = &ev
|
|
259
|
+
}
|
|
260
|
+
}); err != nil {
|
|
248
261
|
t.Fatalf("Stream 失败: %v", err)
|
|
249
262
|
}
|
|
250
263
|
if len(got) != 2 || got[0] != "你好" || got[1] != ",世界" {
|
|
251
264
|
t.Errorf("增量 = %v want [你好 ,世界]", got)
|
|
252
265
|
}
|
|
266
|
+
// 轮次边界:turn_end 带权威全文(与 claude / dsh 同一约定,见 finish 的注释)。
|
|
267
|
+
if end == nil {
|
|
268
|
+
t.Fatal("缺少 turn_end 轮次边界事件")
|
|
269
|
+
}
|
|
270
|
+
if end.Text != "你好,世界" {
|
|
271
|
+
t.Errorf("turn_end.Text = %q,want 权威全文「你好,世界」", end.Text)
|
|
272
|
+
}
|
|
253
273
|
}
|
|
254
274
|
|
|
255
275
|
func TestCodeBuddyGatewayErrorFrame(t *testing.T) {
|
|
@@ -671,3 +691,218 @@ func TestCodeBuddyGatewayStreamTimeout(t *testing.T) {
|
|
|
671
691
|
t.Errorf("超时没生效,耗时 %v", time.Since(start))
|
|
672
692
|
}
|
|
673
693
|
}
|
|
694
|
+
|
|
695
|
+
// ---------- 状态收敛(2026-10-02)----------
|
|
696
|
+
|
|
697
|
+
// TestCodeBuddyGatewayConverges 整条流必须给出「同 item id、单调 revision、
|
|
698
|
+
// running…→ final」的完整收敛序列。
|
|
699
|
+
//
|
|
700
|
+
// 这条挡的是「接了 tracker 但没接进 emit 路径」这类接线错误 ——
|
|
701
|
+
// 单元测试全绿但 wire 上没有 item_id,症状是消费方拿不到收敛能力。
|
|
702
|
+
func TestCodeBuddyGatewayConverges(t *testing.T) {
|
|
703
|
+
frames := []string{
|
|
704
|
+
cbGatewayFrameJSON("streaming", `"content":{"chunk":"第一"}`),
|
|
705
|
+
cbGatewayFrameJSON("streaming", `"content":{"chunk":"第二"}`),
|
|
706
|
+
cbGatewayFrameJSON("completed", `"content":{"markdown":"第一第二"}`),
|
|
707
|
+
}
|
|
708
|
+
srv, _ := cbGatewayTestServer(t, "run-conv-1", frames)
|
|
709
|
+
e := cbGatewayServerEngine(srv)
|
|
710
|
+
|
|
711
|
+
var evs []StreamEvent
|
|
712
|
+
if _, err := e.Stream(context.Background(), Request{
|
|
713
|
+
Messages: []Message{{Role: "user", Content: "x"}},
|
|
714
|
+
}, func(ev StreamEvent) { evs = append(evs, ev) }); err != nil {
|
|
715
|
+
t.Fatalf("Stream 失败: %v", err)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
var texts []StreamEvent
|
|
719
|
+
var ends []StreamEvent
|
|
720
|
+
for _, ev := range evs {
|
|
721
|
+
switch ev.Kind {
|
|
722
|
+
case KindText:
|
|
723
|
+
texts = append(texts, ev)
|
|
724
|
+
case KindTurnEnd:
|
|
725
|
+
ends = append(ends, ev)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// 两帧 streaming 各一条正文;completed 帧正文与已发增量相同(前缀去重后差量为空),
|
|
729
|
+
// 所以正文只有 2 条,轮次边界由 turn_end 承担。
|
|
730
|
+
if len(texts) != 2 {
|
|
731
|
+
t.Fatalf("text 事件数 = %d,want 2:%+v", len(texts), texts)
|
|
732
|
+
}
|
|
733
|
+
if len(ends) != 1 {
|
|
734
|
+
t.Fatalf("turn_end 事件数 = %d,want 1:%+v", len(ends), ends)
|
|
735
|
+
}
|
|
736
|
+
// turn_end 承担 final:同 item id + 更高 revision。
|
|
737
|
+
if ends[0].ItemID != texts[0].ItemID {
|
|
738
|
+
t.Errorf("turn_end 应挂在正文 item 上:%q vs %q", ends[0].ItemID, texts[0].ItemID)
|
|
739
|
+
}
|
|
740
|
+
if ends[0].ItemRevision <= texts[len(texts)-1].ItemRevision {
|
|
741
|
+
t.Errorf("turn_end revision 必须更高:正文末帧=%d turn_end=%d",
|
|
742
|
+
texts[len(texts)-1].ItemRevision, ends[0].ItemRevision)
|
|
743
|
+
}
|
|
744
|
+
if ends[0].Status != StatusFinal {
|
|
745
|
+
t.Errorf("turn_end status = %q,want final", ends[0].Status)
|
|
746
|
+
}
|
|
747
|
+
for i, ev := range texts {
|
|
748
|
+
if ev.ItemID == "" || ev.ItemRevision == 0 {
|
|
749
|
+
t.Errorf("[%d] 缺收敛字段: %+v", i, ev)
|
|
750
|
+
}
|
|
751
|
+
if !strings.HasPrefix(ev.ItemID, "codebuddy-gateway_msg_") {
|
|
752
|
+
t.Errorf("[%d] item id 前缀不对: %q", i, ev.ItemID)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// 同一条 item(整轮只有一个 run)→ id 全同。
|
|
756
|
+
for i, ev := range texts {
|
|
757
|
+
if ev.ItemID != texts[0].ItemID {
|
|
758
|
+
t.Errorf("[%d] item id 变了:%q vs %q", i, ev.ItemID, texts[0].ItemID)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// revision 严格递增,末帧为 final。
|
|
762
|
+
for i := 1; i < len(texts); i++ {
|
|
763
|
+
if texts[i].ItemRevision <= texts[i-1].ItemRevision {
|
|
764
|
+
t.Errorf("revision 未递增:%d 之后是 %d", texts[i-1].ItemRevision, texts[i].ItemRevision)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if texts[0].Status != StatusRunning {
|
|
768
|
+
t.Errorf("首帧 status = %q,want running", texts[0].Status)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// TestCodeBuddyGatewaySnapshotIsCumulative snapshot 必须是**累积整段**,
|
|
773
|
+
// text 才是增量 —— 两者混了就等于没有收敛能力。
|
|
774
|
+
//
|
|
775
|
+
// 特别验 completed 帧那一帧:传入 emitText 的是「差量」(此处为空所以不发),
|
|
776
|
+
// 但 snapshot 仍要等于累积到此刻的全文,不能是差量或空串。
|
|
777
|
+
func TestCodeBuddyGatewaySnapshotIsCumulative(t *testing.T) {
|
|
778
|
+
frames := []string{
|
|
779
|
+
cbGatewayFrameJSON("streaming", `"content":{"chunk":"你好"}`),
|
|
780
|
+
cbGatewayFrameJSON("streaming", `"content":{"chunk":"世界"}`),
|
|
781
|
+
cbGatewayFrameJSON("completed", `"content":{"markdown":"你好世界!"}`),
|
|
782
|
+
}
|
|
783
|
+
srv, _ := cbGatewayTestServer(t, "run-conv-2", frames)
|
|
784
|
+
e := cbGatewayServerEngine(srv)
|
|
785
|
+
|
|
786
|
+
var texts, ends []StreamEvent
|
|
787
|
+
if _, err := e.Stream(context.Background(), Request{
|
|
788
|
+
Messages: []Message{{Role: "user", Content: "x"}},
|
|
789
|
+
}, func(ev StreamEvent) {
|
|
790
|
+
switch ev.Kind {
|
|
791
|
+
case KindText:
|
|
792
|
+
texts = append(texts, ev)
|
|
793
|
+
case KindTurnEnd:
|
|
794
|
+
ends = append(ends, ev)
|
|
795
|
+
}
|
|
796
|
+
}); err != nil {
|
|
797
|
+
t.Fatalf("Stream 失败: %v", err)
|
|
798
|
+
}
|
|
799
|
+
if len(texts) != 3 {
|
|
800
|
+
t.Fatalf("text 事件数 = %d,want 3:%+v", len(texts), texts)
|
|
801
|
+
}
|
|
802
|
+
if len(ends) != 1 {
|
|
803
|
+
t.Fatalf("turn_end 事件数 = %d,want 1:%+v", len(ends), ends)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 逐帧校验:text 是增量,snapshot 是累积。
|
|
807
|
+
wantDelta := []string{"你好", "世界", "!"}
|
|
808
|
+
wantSnap := []string{"你好", "你好世界", "你好世界!"}
|
|
809
|
+
for i, ev := range texts {
|
|
810
|
+
if ev.Text != wantDelta[i] {
|
|
811
|
+
t.Errorf("[%d] text = %q,want 增量 %q", i, ev.Text, wantDelta[i])
|
|
812
|
+
}
|
|
813
|
+
if ev.Snapshot != wantSnap[i] {
|
|
814
|
+
t.Errorf("[%d] snapshot = %q,want 累积 %q", i, ev.Snapshot, wantSnap[i])
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// 终帧(turn_end)的 snapshot 必须是完整正文。
|
|
818
|
+
if ends[0].Snapshot != "你好世界!" {
|
|
819
|
+
t.Errorf("turn_end snapshot = %q,want 全文", ends[0].Snapshot)
|
|
820
|
+
}
|
|
821
|
+
if ends[0].Text != "你好世界!" {
|
|
822
|
+
t.Errorf("turn_end text = %q,want 权威全文", ends[0].Text)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// TestCodeBuddyGatewayNoItemIDWithoutEvents onEvent 为 nil(非流式 Complete 路径)
|
|
827
|
+
// 时不发任何事件,自然也不该构造 tracker。
|
|
828
|
+
func TestCodeBuddyGatewayCompleteHasNoItemID(t *testing.T) {
|
|
829
|
+
frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":"完成"}`)}
|
|
830
|
+
srv, _ := cbGatewayTestServer(t, "run-conv-3", frames)
|
|
831
|
+
e := cbGatewayServerEngine(srv)
|
|
832
|
+
|
|
833
|
+
res, err := e.Complete(context.Background(), Request{
|
|
834
|
+
Messages: []Message{{Role: "user", Content: "x"}},
|
|
835
|
+
})
|
|
836
|
+
if err != nil {
|
|
837
|
+
t.Fatalf("Complete 失败: %v", err)
|
|
838
|
+
}
|
|
839
|
+
if res.Text != "完成" {
|
|
840
|
+
t.Errorf("Text = %q", res.Text)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// TestCodeBuddyGatewayTurnFailedOnTruncatedStream 流被截断(没有 completed 帧)
|
|
845
|
+
// 必须补 turn_failed,否则那条 item 永远停在 running —— 消费方只能靠超时猜,
|
|
846
|
+
// 历史回放里那一条会永远显示「生成中」。
|
|
847
|
+
func TestCodeBuddyGatewayTurnFailedOnTruncatedStream(t *testing.T) {
|
|
848
|
+
frames := []string{
|
|
849
|
+
cbGatewayFrameJSON("streaming", `"content":{"chunk":"只说了半句"}`),
|
|
850
|
+
}
|
|
851
|
+
srv, _ := cbGatewayTestServer(t, "run-fail-1", frames)
|
|
852
|
+
e := cbGatewayServerEngine(srv)
|
|
853
|
+
|
|
854
|
+
var texts, failed []StreamEvent
|
|
855
|
+
_, err := e.Stream(context.Background(), Request{
|
|
856
|
+
Messages: []Message{{Role: "user", Content: "x"}},
|
|
857
|
+
}, func(ev StreamEvent) {
|
|
858
|
+
switch ev.Kind {
|
|
859
|
+
case KindText:
|
|
860
|
+
texts = append(texts, ev)
|
|
861
|
+
case KindTurnFailed:
|
|
862
|
+
failed = append(failed, ev)
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
if err == nil {
|
|
866
|
+
t.Fatal("无终帧的流应报错")
|
|
867
|
+
}
|
|
868
|
+
if len(texts) != 1 || len(failed) != 1 {
|
|
869
|
+
t.Fatalf("事件形状 = text %d / turn_failed %d,want 1/1", len(texts), len(failed))
|
|
870
|
+
}
|
|
871
|
+
// failed 挂在同一个 item 上:partial 还在,只是这条 item 不再增长。
|
|
872
|
+
if failed[0].ItemID != texts[0].ItemID {
|
|
873
|
+
t.Errorf("turn_failed 应挂在正文 item 上:%q vs %q", failed[0].ItemID, texts[0].ItemID)
|
|
874
|
+
}
|
|
875
|
+
if failed[0].Status != StatusFailed {
|
|
876
|
+
t.Errorf("status = %q,want failed", failed[0].Status)
|
|
877
|
+
}
|
|
878
|
+
if failed[0].ItemRevision <= texts[0].ItemRevision {
|
|
879
|
+
t.Errorf("turn_failed revision 必须更高:%d vs %d", failed[0].ItemRevision, texts[0].ItemRevision)
|
|
880
|
+
}
|
|
881
|
+
if failed[0].Error == "" || failed[0].Reason == "" {
|
|
882
|
+
t.Errorf("turn_failed 必须带 error / reason:%+v", failed[0])
|
|
883
|
+
}
|
|
884
|
+
if failed[0].Snapshot != "只说了半句" {
|
|
885
|
+
t.Errorf("snapshot = %q,want 保留 partial", failed[0].Snapshot)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// TestCodeBuddyGatewayNoTurnFailedWithoutText 一句正文都没流出来时**不该**造
|
|
890
|
+
// failed 帧:消费方会凭空多出一条空 item。
|
|
891
|
+
func TestCodeBuddyGatewayNoTurnFailedWithoutText(t *testing.T) {
|
|
892
|
+
// 只有受理帧,没有 streaming / completed。
|
|
893
|
+
srv, _ := cbGatewayTestServer(t, "run-empty-1", []string{
|
|
894
|
+
cbGatewayFrameJSON("accepted", ""),
|
|
895
|
+
})
|
|
896
|
+
e := cbGatewayServerEngine(srv)
|
|
897
|
+
|
|
898
|
+
var kinds []StreamEventKind
|
|
899
|
+
_, err := e.Stream(context.Background(), Request{
|
|
900
|
+
Messages: []Message{{Role: "user", Content: "x"}},
|
|
901
|
+
}, func(ev StreamEvent) { kinds = append(kinds, ev.Kind) })
|
|
902
|
+
if err == nil {
|
|
903
|
+
t.Fatal("无终帧的流应报错")
|
|
904
|
+
}
|
|
905
|
+
if len(kinds) != 0 {
|
|
906
|
+
t.Errorf("没有正文时不该发任何事件,收到 %v", kinds)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// codebuddy_stream_test.go - codebuddy 专属流式协议差异的回归测试(2026-10-01)。
|
|
4
|
+
//
|
|
5
|
+
// codebuddy 与 claude 同源但**协议有两处不同**,两处都只表现为
|
|
6
|
+
// 「输出为空 / 输出里混进英文推理」,不报错、退出码 0:
|
|
7
|
+
//
|
|
8
|
+
// 1. 正文走 assistant 聚合行兜底(AggregateOnly)。协议形态变过两次:
|
|
9
|
+
// 2026-10-01 只发聚合行、不发 delta(不开 AggregateOnly → `-o text`
|
|
10
|
+
// 实时输出全程空白);2026-10-11 新版 CLI 增量与聚合行**并存**
|
|
11
|
+
// (聚合行无条件重发全文 → snapshot 全文×2 → 移动端「消息显示重复」)。
|
|
12
|
+
// 两条形态都有测试钉住。
|
|
13
|
+
// 2. 思维链以 <think>…</think> 塞进正文 result 字符串。
|
|
14
|
+
// 不剥 → 用户看到的回答前面挂着一段英文推理。
|
|
15
|
+
//
|
|
16
|
+
// 两个开关都是"多发/少发"级别的错,所以必须有断言钉住。
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"strings"
|
|
20
|
+
"testing"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// TestAggregateOnlyEmitsFromAssistantLine AggregateOnly 引擎的正文必须实时出来。
|
|
24
|
+
func TestAggregateOnlyEmitsFromAssistantLine(t *testing.T) {
|
|
25
|
+
lines := []string{
|
|
26
|
+
`{"type":"system","subtype":"init","session_id":"cb-1","model":"hy3"}`,
|
|
27
|
+
`{"type":"assistant","session_id":"cb-1","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
|
28
|
+
`{"type":"result","subtype":"success","is_error":false,"result":"你好","session_id":"cb-1"}`,
|
|
29
|
+
}
|
|
30
|
+
acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
|
|
31
|
+
evs := feedNDJSON(t, acc, lines)
|
|
32
|
+
|
|
33
|
+
var texts []string
|
|
34
|
+
for _, ev := range evs {
|
|
35
|
+
if ev.Kind == KindText {
|
|
36
|
+
texts = append(texts, ev.Text)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if len(texts) != 1 || texts[0] != "你好" {
|
|
40
|
+
t.Fatalf("正文事件 = %q,want 恰好一条「你好」", texts)
|
|
41
|
+
}
|
|
42
|
+
if acc.Text.String() != "你好" {
|
|
43
|
+
t.Fatalf("acc.Text = %q", acc.Text.String())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// TestAggregateOnlyOffDoesNotDuplicate 非 AggregateOnly 引擎(claude/trae)
|
|
48
|
+
// 绝不能从聚合行再发一遍正文 —— 增量已经在 stream_event 里发过了。
|
|
49
|
+
//
|
|
50
|
+
// 这条是「AggregateOnly 必须由引擎显式声明、不能全局打开」的护栏。
|
|
51
|
+
func TestAggregateOnlyOffDoesNotDuplicate(t *testing.T) {
|
|
52
|
+
lines := []string{
|
|
53
|
+
// 增量路径(claude 正常形态)
|
|
54
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}}`,
|
|
55
|
+
// 同一轮的聚合行
|
|
56
|
+
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
|
57
|
+
}
|
|
58
|
+
acc := &streamAccumulator{Engine: "claude"} // AggregateOnly 缺省 false
|
|
59
|
+
evs := feedNDJSON(t, acc, lines)
|
|
60
|
+
|
|
61
|
+
n := 0
|
|
62
|
+
for _, ev := range evs {
|
|
63
|
+
if ev.Kind == KindText {
|
|
64
|
+
n++
|
|
65
|
+
if ev.Text != "你好" {
|
|
66
|
+
t.Fatalf("正文事件 = %q", ev.Text)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if n != 1 {
|
|
71
|
+
t.Fatalf("正文事件条数 = %d,want 1(开了就重复输出了)", n)
|
|
72
|
+
}
|
|
73
|
+
if acc.Text.String() != "你好" {
|
|
74
|
+
t.Fatalf("acc.Text = %q,want 只有一份", acc.Text.String())
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// TestAggregateOnlyCoexistsWithDelta 回归(2026-10-11,移动端「消息显示重复」):
|
|
79
|
+
// 新版 codebuddy CLI 增量与聚合行**并存** —— stream_event 逐字发正文,
|
|
80
|
+
// 流尾 assistant 聚合行再给一次整段全文。emitAggregateText 若无条件把
|
|
81
|
+
// 全文当增量 emit,snapshot 会变成「全文×2」(增量桶 + 0 号聚合桶并存,
|
|
82
|
+
// joinBlocks 拼接),移动端 upsert 后整条消息显示两遍。
|
|
83
|
+
func TestAggregateOnlyCoexistsWithDelta(t *testing.T) {
|
|
84
|
+
lines := []string{
|
|
85
|
+
`{"type":"system","subtype":"init","session_id":"cb-2"}`,
|
|
86
|
+
`{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}`,
|
|
87
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}}`,
|
|
88
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"世界"}}}`,
|
|
89
|
+
`{"type":"stream_event","event":{"type":"content_block_stop","index":0}}`,
|
|
90
|
+
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好世界"}]}}`,
|
|
91
|
+
`{"type":"result","subtype":"success","is_error":false,"result":"你好世界","session_id":"cb-2"}`,
|
|
92
|
+
}
|
|
93
|
+
acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
|
|
94
|
+
evs := feedNDJSON(t, acc, lines)
|
|
95
|
+
|
|
96
|
+
var joined, lastSnap string
|
|
97
|
+
for _, ev := range evs {
|
|
98
|
+
if ev.Kind != KindText {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
joined += ev.Text
|
|
102
|
+
lastSnap = ev.Snapshot
|
|
103
|
+
if strings.Contains(ev.Snapshot, "你好世界你好世界") {
|
|
104
|
+
t.Fatalf("snapshot 重复累加了全文:%q", ev.Snapshot)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if lastSnap != "你好世界" {
|
|
108
|
+
t.Fatalf("最终 snapshot = %q,want 恰好一份「你好世界」", lastSnap)
|
|
109
|
+
}
|
|
110
|
+
if joined != "你好世界" {
|
|
111
|
+
t.Fatalf("text 事件流 = %q,want 拼起来恰好一份", joined)
|
|
112
|
+
}
|
|
113
|
+
if acc.Text.String() != "你好世界" {
|
|
114
|
+
t.Fatalf("acc.Text = %q,want 只有一份", acc.Text.String())
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// TestAggregateOnlyEmitsOnlyDeltaTail 聚合行比已发增量多尾巴(增量被截断)
|
|
119
|
+
// 时只补差量,不整段重发 —— 重发会让终端直打多一份全文。
|
|
120
|
+
func TestAggregateOnlyEmitsOnlyDeltaTail(t *testing.T) {
|
|
121
|
+
lines := []string{
|
|
122
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}}`,
|
|
123
|
+
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好世界"}]}}`,
|
|
124
|
+
}
|
|
125
|
+
acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
|
|
126
|
+
evs := feedNDJSON(t, acc, lines)
|
|
127
|
+
|
|
128
|
+
var texts []string
|
|
129
|
+
for _, ev := range evs {
|
|
130
|
+
if ev.Kind == KindText {
|
|
131
|
+
texts = append(texts, ev.Text)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if len(texts) != 2 || texts[0] != "你好" || texts[1] != "世界" {
|
|
135
|
+
t.Fatalf("text 事件 = %q,want [你好 世界](只补差量)", texts)
|
|
136
|
+
}
|
|
137
|
+
if acc.Text.String() != "你好世界" {
|
|
138
|
+
t.Fatalf("acc.Text = %q", acc.Text.String())
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// TestAggregateOnlyStripsThinkFromLiveOutput 实时输出里不能出现思维链。
|
|
143
|
+
func TestAggregateOnlyStripsThinkFromLiveOutput(t *testing.T) {
|
|
144
|
+
lines := []string{
|
|
145
|
+
`{"type":"assistant","message":{"role":"assistant","content":"<think>模型在推理英文</think>\n最终答案是 42"}}`,
|
|
146
|
+
}
|
|
147
|
+
acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
|
|
148
|
+
evs := feedNDJSON(t, acc, lines)
|
|
149
|
+
|
|
150
|
+
var joined string
|
|
151
|
+
for _, ev := range evs {
|
|
152
|
+
if ev.Kind == KindText {
|
|
153
|
+
joined += ev.Text
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if strings.Contains(joined, "<think>") || strings.Contains(joined, "模型在推理英文") {
|
|
157
|
+
t.Fatalf("实时输出里混进了思维链:%q", joined)
|
|
158
|
+
}
|
|
159
|
+
if !strings.Contains(joined, "最终答案是 42") {
|
|
160
|
+
t.Fatalf("正文丢了:%q", joined)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// TestAggregateOnlySkipsToolBlocks 聚合行里的工具块不能被当正文发出来。
|
|
165
|
+
func TestAggregateOnlySkipsToolBlocks(t *testing.T) {
|
|
166
|
+
line := `{"type":"assistant","message":{"role":"assistant","content":[` +
|
|
167
|
+
`{"type":"thinking","thinking":"内部推理"},` +
|
|
168
|
+
`{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"ls"}},` +
|
|
169
|
+
`{"type":"text","text":"命令已执行"}` +
|
|
170
|
+
`]}}`
|
|
171
|
+
acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
|
|
172
|
+
evs := feedNDJSON(t, acc, []string{line})
|
|
173
|
+
|
|
174
|
+
var joined string
|
|
175
|
+
for _, ev := range evs {
|
|
176
|
+
if ev.Kind == KindText {
|
|
177
|
+
joined += ev.Text
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if joined != "命令已执行" {
|
|
181
|
+
t.Fatalf("正文 = %q,want 只有「命令已执行」", joined)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// TestTurnEndSanitized 回归(2026-10-01 真实 E2E 抓到):
|
|
186
|
+
// `-o json` 的 NDJSON 里 text 干净、turn_end 却夹着 <think>…</think>。
|
|
187
|
+
//
|
|
188
|
+
// 根因:turn_end 的 Text 直接取 result 行的**原始** result 字段
|
|
189
|
+
// (runStreamJSONIn 里 acc.emitTurnEnd(fin.Result, ...)),而同一轮的
|
|
190
|
+
// text / result 都过了 stripThinkBlock。三个来源干净程度不一致。
|
|
191
|
+
// 消费方把 turn_end 当轮次边界,推理就会被当成正文收进去。
|
|
192
|
+
func TestTurnEndSanitized(t *testing.T) {
|
|
193
|
+
raw := `<think>The user asks… reasoning…</think>` + "\n\n最终答案"
|
|
194
|
+
acc := &streamAccumulator{Engine: "codebuddy", SanitizeTurnText: stripThinkBlock}
|
|
195
|
+
acc.OnEvent = func(ev StreamEvent) {
|
|
196
|
+
if ev.Kind != KindTurnEnd {
|
|
197
|
+
t.Fatalf("不该有别的 kind:%v", ev.Kind)
|
|
198
|
+
}
|
|
199
|
+
if strings.Contains(ev.Text, "<think>") || strings.Contains(ev.Text, "reasoning") {
|
|
200
|
+
t.Fatalf("turn_end 仍带思维链:%q", ev.Text)
|
|
201
|
+
}
|
|
202
|
+
if ev.Text != "最终答案" {
|
|
203
|
+
t.Fatalf("turn_end = %q,want「最终答案」", ev.Text)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
acc.emitTurnEnd(raw, "s-1")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// TestTurnEndUntouchedWhenNoSanitizer 没声明清洗器的引擎(claude)必须原样透传。
|
|
210
|
+
//
|
|
211
|
+
// 挡的是「在 emitTurnEnd 里对所有引擎无脑套 think 剥离」:
|
|
212
|
+
// 那样会切掉合法输出里恰好出现的 <think> 字面量。
|
|
213
|
+
func TestTurnEndUntouchedWhenNoSanitizer(t *testing.T) {
|
|
214
|
+
const raw = "正常正文,含 <think> 字面量"
|
|
215
|
+
acc := &streamAccumulator{Engine: "claude"} // SanitizeTurnText 为 nil
|
|
216
|
+
got := ""
|
|
217
|
+
acc.OnEvent = func(ev StreamEvent) { got = ev.Text }
|
|
218
|
+
acc.emitTurnEnd(raw, "s-1")
|
|
219
|
+
if got != raw {
|
|
220
|
+
t.Fatalf("claude 的 turn_end 被改了:%q,want %q", got, raw)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// TestAssistantLineTextShapes assistantLineText 的两种 content 形态。
|
|
225
|
+
func TestAssistantLineTextShapes(t *testing.T) {
|
|
226
|
+
if got := assistantLineText(`{"message":{"content":"纯字符串"}}`); got != "纯字符串" {
|
|
227
|
+
t.Errorf("字符串形态 = %q", got)
|
|
228
|
+
}
|
|
229
|
+
if got := assistantLineText(`{"message":{"content":[]}}`); got != "" {
|
|
230
|
+
t.Errorf("空数组 = %q", got)
|
|
231
|
+
}
|
|
232
|
+
if got := assistantLineText(`{}`); got != "" {
|
|
233
|
+
t.Errorf("无 content = %q", got)
|
|
234
|
+
}
|
|
235
|
+
if got := assistantLineText(`not json`); got != "" {
|
|
236
|
+
t.Errorf("非 JSON = %q", got)
|
|
237
|
+
}
|
|
238
|
+
// 多段 text 用换行连接(不用空格:正文里空格可能有语义)。
|
|
239
|
+
if got := assistantLineText(`{"message":{"content":[{"type":"text","text":"A"},{"type":"text","text":"B"}]}}`); got != "A\nB" {
|
|
240
|
+
t.Errorf("多段拼接 = %q,want A\\nB", got)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// TestStripThinkBlock 思维链剥离的边界行为。
|
|
245
|
+
func TestStripThinkBlock(t *testing.T) {
|
|
246
|
+
cases := []struct{ name, in, want string }{
|
|
247
|
+
{"无标签原样", "普通正文", "普通正文"},
|
|
248
|
+
{"前置思维链被剥", "<think>abc</think>正文", "正文"},
|
|
249
|
+
{"多段只留最后一个之后", "<think>a</think>中<think>b</think>尾", "尾"},
|
|
250
|
+
{"只有思维链返回空", "<think>只有推理</think>", ""},
|
|
251
|
+
{"未闭合不动", "<think>没闭合", "<think>没闭合"},
|
|
252
|
+
{"空串", "", ""},
|
|
253
|
+
{"闭标签无开标签", "</think>孤立", "</think>孤立"},
|
|
254
|
+
}
|
|
255
|
+
for _, c := range cases {
|
|
256
|
+
t.Run(c.name, func(t *testing.T) {
|
|
257
|
+
if got := stripThinkBlock(c.in); got != c.want {
|
|
258
|
+
t.Errorf("stripThinkBlock(%q) = %q,want %q", c.in, got, c.want)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
package/internal/agent/codex.go
CHANGED
|
@@ -83,8 +83,8 @@ type CodexEngine struct {
|
|
|
83
83
|
// Name 实现 Engine。
|
|
84
84
|
func (e *CodexEngine) Name() string { return "codex" }
|
|
85
85
|
|
|
86
|
-
// DefaultCodexTimeout 单次尝试默认超时(codex
|
|
87
|
-
const DefaultCodexTimeout =
|
|
86
|
+
// DefaultCodexTimeout 单次尝试默认超时(codex 沙箱+网路,整体偏慢;2026-10-10 由 8 分钟统一上调到 1 小时)。
|
|
87
|
+
const DefaultCodexTimeout = time.Hour
|
|
88
88
|
|
|
89
89
|
// bin 探测 codex CLI 路径(委托 cliBase 统一探测链)。
|
|
90
90
|
func (e *CodexEngine) bin() string {
|
|
@@ -319,7 +319,7 @@ func (e *CodexEngine) Stream(ctx context.Context, req Request, onEvent func(Stre
|
|
|
319
319
|
extraEnv = append(extraEnv, "CODEX_HOME="+codexHome)
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
acc := &codexStreamAcc{OnEvent: onEvent}
|
|
322
|
+
acc := &codexStreamAcc{OnEvent: onEvent, OnSessionID: req.OnSessionID}
|
|
323
323
|
if err := runStreamCLIEnvIn(ctx, req.Workspace, extraEnv, bin, args, acc.handleLine); err != nil {
|
|
324
324
|
return StreamResult{}, err
|
|
325
325
|
}
|
|
@@ -351,6 +351,13 @@ type codexStreamAcc struct {
|
|
|
351
351
|
Thinking strings.Builder
|
|
352
352
|
SessionID string
|
|
353
353
|
Usage codexTurnUsage
|
|
354
|
+
// OnSessionID 见 Request.OnSessionID:codex 在流**最开头**的 thread.started 就给了
|
|
355
|
+
// thread_id,这里当场上报(2026-10-18——此前 codex 全程不调,是唯一漏掉的一脉)。
|
|
356
|
+
//
|
|
357
|
+
// 不报的后果不是「少一个回调」:日志 writer 拿不到 id 就永远按 `<run_id>.jsonl` 落盘,
|
|
358
|
+
// 而客户端手里的主键是 session_id —— 点开读到空历史(列表 has_log=true、详情「还没有事件」)。
|
|
359
|
+
// 别的一等引擎(claude / codebuddy / dsh / trae)都在这一层就报了,见各自 Stream 里的接线。
|
|
360
|
+
OnSessionID func(string)
|
|
354
361
|
}
|
|
355
362
|
|
|
356
363
|
func (a *codexStreamAcc) emit(ev StreamEvent) {
|
|
@@ -375,6 +382,9 @@ func (a *codexStreamAcc) handleLine(line string) error {
|
|
|
375
382
|
var ts codexThreadStarted
|
|
376
383
|
if json.Unmarshal([]byte(s), &ts) == nil && ts.ThreadID != "" {
|
|
377
384
|
a.SessionID = ts.ThreadID
|
|
385
|
+
if a.OnSessionID != nil {
|
|
386
|
+
a.OnSessionID(ts.ThreadID)
|
|
387
|
+
}
|
|
378
388
|
}
|
|
379
389
|
case "item.started", "item.completed":
|
|
380
390
|
a.handleItem(s, head.Type)
|
|
@@ -503,20 +513,36 @@ func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string)
|
|
|
503
513
|
}
|
|
504
514
|
// 通用 flags(放在子命令之后,prompt 之前)
|
|
505
515
|
args = append(args, "--skip-git-repo-check", "--json")
|
|
516
|
+
/* 续接轮(`exec resume`)只认一小组覆写参数 —— 0.162.0 的 `--help` 实测:
|
|
517
|
+
`-c / --last / --all / --skip-git-repo-check / --json / -o / --output-schema /
|
|
518
|
+
--ephemeral` 等,**没有 `-C`、也没有 `-m`**。传了 clap 直接
|
|
519
|
+
`error: unexpected argument '-C' found` + exit 2、stdout 全空 → core 侧只能报
|
|
520
|
+
「codex CLI returned empty result · 总耗时 0.0s」(2026-10-18 用户手机上那条
|
|
521
|
+
「继续」就是这么死的)。所以下面这几项一律按子命令分流。 */
|
|
522
|
+
resuming := req.SessionID != "" || req.Continue
|
|
506
523
|
// 工作目录(workspace):codex 有原生 `-C/--cd`(working root),优先用它 ——
|
|
507
524
|
// 它同时决定 sandbox 的可写根与相对路径解析;子进程 cwd 也一并设置(见调用点)。
|
|
508
|
-
|
|
525
|
+
// ⚠️ 续接轮没有 `-C` 可用,只能靠子进程 cwd(调用点已设),sandbox 根延用首次会话。
|
|
526
|
+
if ws := strings.TrimSpace(req.Workspace); ws != "" && !resuming {
|
|
509
527
|
args = append(args, "-C", ws)
|
|
510
528
|
}
|
|
511
529
|
// 注意:不能加 --ephemeral。codex 0.133 在 --ephemeral 模式下不会把
|
|
512
530
|
// session rollout 落盘,后续 --session 续接时报 "no rollout found for
|
|
513
531
|
// thread id"(实测 2026-09-16)。为保持与 claude 一致的"默认新会话可续接"策略,
|
|
514
532
|
// 始终不传 --ephemeral。
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
533
|
+
// 模型:`-m` 同样只属于 `codex exec`;续接轮改用配置覆写 `-c model="<m>"`
|
|
534
|
+
// (codex 帮助里就是拿 model 当 `-c` 的例子)。
|
|
535
|
+
if m := codexModelFor(req, e); m != "" {
|
|
536
|
+
if resuming {
|
|
537
|
+
args = append(args, "-c", "model="+tomlBasicString(m))
|
|
538
|
+
} else {
|
|
539
|
+
args = append(args, "-m", m)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// 推理档位:`-c` 在 `exec` 与 `exec resume` 下都可用(同 model 覆写),
|
|
543
|
+
// 故不按子命令分流。codex 的取值:minimal/low/medium/high/xhigh/max。
|
|
544
|
+
if lv := ReasoningLevel(req.Reasoning); lv != "" {
|
|
545
|
+
args = append(args, "-c", "model_reasoning_effort="+tomlBasicString(lv))
|
|
520
546
|
}
|
|
521
547
|
// SystemPrompt → -c developer_instructions=<TOML 字符串>
|
|
522
548
|
if req.SystemPrompt != "" {
|
|
@@ -566,6 +592,21 @@ func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string)
|
|
|
566
592
|
return args
|
|
567
593
|
}
|
|
568
594
|
|
|
595
|
+
// codexModelFor 定这一轮用哪个模型:请求显式指定优先,其次引擎配置(都去掉 `codex/` 前缀)。
|
|
596
|
+
//
|
|
597
|
+
// 为什么不复用 reqModelForCodex:那个的优先级是「引擎配置优先」(给收尾的 Response.Model 用),
|
|
598
|
+
// 与这里**请求优先**的语义相反,混用会让 `-p --model X` 在 buildArgs 里失效。
|
|
599
|
+
// 抽成函数只为一件事:调用点要按子命令决定用 `-m` 还是 `-c model=`(见 buildArgs)。
|
|
600
|
+
func codexModelFor(req Request, e *CodexEngine) string {
|
|
601
|
+
if m := stripModelPrefix(req.Model); m != "" {
|
|
602
|
+
return m
|
|
603
|
+
}
|
|
604
|
+
if e.Model != "" {
|
|
605
|
+
return stripModelPrefix(e.Model)
|
|
606
|
+
}
|
|
607
|
+
return ""
|
|
608
|
+
}
|
|
609
|
+
|
|
569
610
|
// codexWebSearchOverride 读 MAGIC_AGENT_CODEX_WEBSEARCH 覆写值。
|
|
570
611
|
// 仅接受 0.154 实测合法值域(disabled/cached/indexed/live),其余忽略。
|
|
571
612
|
func codexWebSearchOverride() string {
|
|
@@ -95,3 +95,33 @@ func TestCodexImplementsStreamer(t *testing.T) {
|
|
|
95
95
|
t.Error("CodexEngine 应实现 Streamer(2026-09-17 补齐)")
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
// codex 必须在流**最开头**就把 thread_id 报给 OnSessionID(2026-10-18)。
|
|
100
|
+
//
|
|
101
|
+
// 日志 writer 靠这个回调把文件名从 `<run_id>.jsonl` 定成 `<session_id>.jsonl`;不报的话
|
|
102
|
+
// 登记表里有 session_id、文件却永远叫 run_id,客户端按 session_id 点开读到空历史
|
|
103
|
+
// (用户:「今天发的就不显示会话」)。claude / codebuddy / dsh / trae 都在这一层报了,
|
|
104
|
+
// codex 此前是唯一漏掉的一脉。
|
|
105
|
+
func TestCodexStreamReportsSessionID(t *testing.T) {
|
|
106
|
+
dir := t.TempDir()
|
|
107
|
+
bin := codexStreamStub(t, dir, []string{
|
|
108
|
+
`{"type":"thread.started","thread_id":"th-123"}`,
|
|
109
|
+
`{"type":"item.completed","item":{"type":"agent_message","text":"好"}}`,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
var ids []string
|
|
113
|
+
_, err := (&CodexEngine{BinPath: bin}).Stream(context.Background(),
|
|
114
|
+
Request{
|
|
115
|
+
Tools: ToolsOn,
|
|
116
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
117
|
+
OnSessionID: func(id string) {
|
|
118
|
+
ids = append(ids, id)
|
|
119
|
+
},
|
|
120
|
+
}, nil)
|
|
121
|
+
if err != nil {
|
|
122
|
+
t.Fatalf("Stream: %v", err)
|
|
123
|
+
}
|
|
124
|
+
if len(ids) != 1 || ids[0] != "th-123" {
|
|
125
|
+
t.Fatalf("应上报一次 thread_id(th-123),实得 %v", ids)
|
|
126
|
+
}
|
|
127
|
+
}
|