magic-agent 1.0.0

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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,88 @@
1
+ package agent
2
+
3
+ // env.go - 子进程环境构造(darwin / linux / windows 通用)。
4
+ //
5
+ // 除继承当前环境(CLI 依赖 HOME/PATH 等基础变量)外,还要剔除若干
6
+ // 「只能属于父进程」的变量。见 childEnvDenylist。
7
+
8
+ import (
9
+ "os"
10
+ "strings"
11
+ )
12
+
13
+ // serverEnvPrefix 剔除的前缀:父会话内置 HTTP 服务的监听地址。
14
+ //
15
+ // WorkBuddy 父会话用 SERVER__HOST / SERVER__PORT(双下划线)把自身
16
+ // 内置 HTTP 服务的监听端口注入给子进程。codebuddy CLI 读到这两个变量后
17
+ // 会在**同一个端口**再起一个服务,撞上父进程已监听 → EADDRINUSE →
18
+ // 启动流程里该错误是 unhandled rejection,CLI 既不退出也不产出任何
19
+ // stdout/stderr,表现为**永久挂起**。
20
+ //
21
+ // 实测(2026-09-15):
22
+ //
23
+ // 继承 SERVER__PORT=<父进程端口> → 100s+ 无输出,必挂
24
+ // 剔除 SERVER__PORT → 4~9s 正常返回
25
+ // 换成任意空闲端口 → 正常返回
26
+ //
27
+ // 这是「在 WorkBuddy 会话里嵌套调用 codebuddy 引擎永远没结果」的根因,
28
+ // 不是 CLI 本身的问题(干净环境下同一命令数秒即回)。因此子进程一律
29
+ // 不继承该前缀;CLI 不需要、也不应该绑定父进程的服务端口。
30
+ const serverEnvPrefix = "SERVER__"
31
+
32
+ // envDenylist 精确剔除的变量名(前缀规则之外的部分)。
33
+ var envDenylist = map[string]bool{
34
+ // 父会话的会话/请求标识:子进程复用会让 CLI 误以为自己是既有会话
35
+ // 的重入调用,可能串到父会话上下文,故一并剔除。
36
+ "CODEBUDDY_SESSION_ID": true,
37
+ "CODEBUDDY_CONVERSATION_REQUEST_ID": true,
38
+ "CODEBUDDY_CONVERSATION_MESSAGE_ID": true,
39
+ "CODEBUDDY_TOOL_CALL_ID": true,
40
+ "CLAUDE_SESSION_ID": true,
41
+
42
+ // Python 解释器路径注入:宿主环境(如 TRAE 会话)会把 PYTHONHOME 指到
43
+ // 自带的 Python.framework、PYTHONPATH 指到跨版本 site-packages。
44
+ // llm CLI 由 venv python 启动,读到这两个变量后 sys.path 错乱,
45
+ // 直接以 path 配置 dump / "<no Python frame>" 退出(实测 2026-09-16)。
46
+ // venv 自带隔离,不需要宿主注入;node 系 CLI(claude/codebuddy/trae)不受影响。
47
+ "PYTHONHOME": true,
48
+ "PYTHONPATH": true,
49
+ }
50
+
51
+ // environ 返回供子 CLI 继承的环境变量(已剔除父进程专属项)。
52
+ func environ() []string {
53
+ src := os.Environ()
54
+ out := make([]string, 0, len(src))
55
+ for _, kv := range src {
56
+ name, _, ok := strings.Cut(kv, "=")
57
+ if !ok {
58
+ continue
59
+ }
60
+ if envDenied(name) {
61
+ continue
62
+ }
63
+ out = append(out, kv)
64
+ }
65
+ return out
66
+ }
67
+
68
+ // ChildEnvWith 返回「子进程应继承的环境(environ(),已剔除父进程专属项)」
69
+ // 追加 extraEnv 后的结果。
70
+ //
71
+ // exec 对重复 key 取**后出现**的那份,所以 extraEnv 可以覆盖继承来的同名变量
72
+ // —— 这正是账号隔离需要的语义(见 codebuddy.go 的 codebuddyAccountEnv)。
73
+ // 交互式登录入口(--login)也用它构造环境,保证与普通调用同源。
74
+ func ChildEnvWith(extraEnv []string) []string {
75
+ if len(extraEnv) == 0 {
76
+ return environ()
77
+ }
78
+ out := environ()
79
+ return append(out, extraEnv...)
80
+ }
81
+
82
+ // envDenied 判断某环境变量是否不允许传给子 CLI。
83
+ func envDenied(name string) bool {
84
+ if strings.HasPrefix(name, serverEnvPrefix) {
85
+ return true
86
+ }
87
+ return envDenylist[name]
88
+ }
@@ -0,0 +1,106 @@
1
+ //go:build !windows
2
+
3
+ package agent
4
+
5
+ // env_test.go - 子进程环境剔除规则回归测试。
6
+ //
7
+ // 背景:WorkBuddy 父会话把 SERVER__PORT 注入给子进程,codebuddy CLI
8
+ // 会用该端口再起一个服务,撞上父进程已监听 → EADDRINUSE → 永久挂起。
9
+ // 本文件锁定「SERVER__ 前缀必须被剔除」这一行为,防止回归。
10
+
11
+ import (
12
+ "os"
13
+ "strings"
14
+ "testing"
15
+ )
16
+
17
+ func TestEnvironStripsServerPrefix(t *testing.T) {
18
+ t.Setenv("SERVER__PORT", "58311")
19
+ t.Setenv("SERVER__HOST", "127.0.0.1")
20
+
21
+ for _, kv := range environ() {
22
+ name, _, _ := strings.Cut(kv, "=")
23
+ if strings.HasPrefix(name, "SERVER__") {
24
+ t.Errorf("SERVER__* 不应传给子 CLI,但出现: %s", kv)
25
+ }
26
+ }
27
+ }
28
+
29
+ func TestEnvironStripsParentSessionIDs(t *testing.T) {
30
+ denied := []string{
31
+ "CODEBUDDY_SESSION_ID",
32
+ "CODEBUDDY_CONVERSATION_REQUEST_ID",
33
+ "CODEBUDDY_CONVERSATION_MESSAGE_ID",
34
+ "CODEBUDDY_TOOL_CALL_ID",
35
+ "CLAUDE_SESSION_ID",
36
+ }
37
+ for _, k := range denied {
38
+ t.Setenv(k, "test-value")
39
+ }
40
+
41
+ got := map[string]bool{}
42
+ for _, kv := range environ() {
43
+ name, _, _ := strings.Cut(kv, "=")
44
+ got[name] = true
45
+ }
46
+ for _, k := range denied {
47
+ if got[k] {
48
+ t.Errorf("父会话标识 %s 不应传子 CLI", k)
49
+ }
50
+ }
51
+ }
52
+
53
+ // 基础变量必须保留:CLI 依赖 HOME/PATH,剔多了会直接跑不起来。
54
+ func TestEnvironKeepsEssentials(t *testing.T) {
55
+ t.Setenv("MAGIC_AGENT_TEST_KEEP", "yes")
56
+ got := map[string]bool{}
57
+ for _, kv := range environ() {
58
+ name, _, _ := strings.Cut(kv, "=")
59
+ got[name] = true
60
+ }
61
+ for _, k := range []string{"HOME", "PATH", "MAGIC_AGENT_TEST_KEEP"} {
62
+ if !got[k] {
63
+ t.Errorf("基础变量 %s 被误剔除", k)
64
+ }
65
+ }
66
+ if _, ok := os.LookupEnv("HOME"); ok && !got["HOME"] {
67
+ t.Error("HOME 必须传递")
68
+ }
69
+ }
70
+
71
+ // 宿主注入的 Python 变量必须剔除:llm 引擎由 venv python 启动,
72
+ // PYTHONHOME / PYTHONPATH 会打乱其 sys.path(见 env.go 注释)。
73
+ func TestEnvironStripsPythonVars(t *testing.T) {
74
+ t.Setenv("PYTHONHOME", "/some/host/python")
75
+ t.Setenv("PYTHONPATH", "/some/host/site-packages")
76
+
77
+ got := map[string]bool{}
78
+ for _, kv := range environ() {
79
+ name, _, _ := strings.Cut(kv, "=")
80
+ got[name] = true
81
+ }
82
+ for _, k := range []string{"PYTHONHOME", "PYTHONPATH"} {
83
+ if got[k] {
84
+ t.Errorf("%s 不应传给子 CLI(会破坏 llm 的 venv 解释器)", k)
85
+ }
86
+ }
87
+ }
88
+
89
+ func TestEnvDenied(t *testing.T) {
90
+ cases := map[string]bool{
91
+ "SERVER__PORT": true,
92
+ "SERVER__HOST": true,
93
+ "SERVER__ANYTHING": true,
94
+ "CODEBUDDY_SESSION_ID": true,
95
+ "CODEBUDDY_SERVICE_PROXY_URL": false, // 保留:CLI 可能依赖父会话服务
96
+ "CODEBUDDY_MCP_CONFIG": false,
97
+ "HOME": false,
98
+ "PATH": false,
99
+ "CODEBUDDY_CONFIG_DIR": false,
100
+ }
101
+ for name, want := range cases {
102
+ if got := envDenied(name); got != want {
103
+ t.Errorf("envDenied(%q) = %v, want %v", name, got, want)
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,128 @@
1
+ package agent
2
+
3
+ // jsonextract.go - llm 引擎的结构化输出后处理。
4
+ //
5
+ // 背景:上游(magic-video)注入 JSON Schema 进 system prompt,要求模型按
6
+ // schema 一次性输出 JSON 对象。llm CLI 走 OpenAI-compatible chat/completions
7
+ // 后返回的 `<response>` 是字符串;模型常把 JSON 嵌套进 `<think>...</think>`
8
+ // 之外的纯文本里,有时还裹了 markdown 围栏(```json ... ```)。直接拿原始
9
+ // 文本会让上游 `json.Unmarshal` 失败。
10
+ //
11
+ // `extractJSONObjectStrict` 用 schema 提供的「顶层必填字段」作为硬判定:
12
+ // 1. 必须能从文本里找到一个 JSON 对象(容忍 ```json 围栏与前后废话);
13
+ // 2. 解出的对象必须是 map[string]any,且所有 schema.Required 都出现在
14
+ // 顶层键里(缺一个就算失败,不兜底);
15
+ // 3. type=object 才执行;其他 schema 类型当前项目不出现。
16
+ //
17
+ // 失败时不改写原文本,让上游解析器(base/score 等已有 json 围栏剥离逻辑)
18
+ // 走兜底。
19
+ //
20
+ // 这条路径只对 llm 引擎启用。其他引擎(codebuddy/trae/claude)通过
21
+ // `--json-schema` flag / 各引擎自带的结构化输出支持处理;它们不进来。
22
+
23
+ import (
24
+ "encoding/json"
25
+ "strings"
26
+ )
27
+
28
+ // extractJSONObjectStrict 尝试从 text 里抽出一个严格匹配 schema 顶层
29
+ // Required 字段集的 JSON 对象,并返回紧凑 re-marshal 后的字符串。
30
+ //
31
+ // 失败时返回 (text, false) —— 调用方应原样用 text,不阻断。
32
+ func extractJSONObjectStrict(text string, schema *JSONSchema) (string, bool) {
33
+ if schema == nil || !strings.EqualFold(schema.Type, "object") {
34
+ return text, false
35
+ }
36
+ if len(schema.Required) == 0 {
37
+ // 没有必填字段 → 不做"严格"判定,避免误判任何对象。
38
+ // 上游有自带的宽松解析(容忍围栏),走兜底即可。
39
+ return text, false
40
+ }
41
+
42
+ raw := strings.TrimSpace(text)
43
+ raw = stripJSONFence(raw)
44
+
45
+ obj, ok := firstJSONObject(raw)
46
+ if !ok {
47
+ return text, false
48
+ }
49
+
50
+ // 严格匹配:schema 顶层 Required 的每个字段都必须出现在 obj 的顶层键里。
51
+ // 注意:只用 m[k] 直接判定;若 model 把字段放在嵌套对象里(如
52
+ // {"scores": {...}})也不通过 —— 上游要的就是扁平字段。
53
+ missing := make([]string, 0, len(schema.Required))
54
+ for _, k := range schema.Required {
55
+ if _, ok := obj[k]; !ok {
56
+ missing = append(missing, k)
57
+ }
58
+ }
59
+ if len(missing) > 0 {
60
+ return text, false
61
+ }
62
+
63
+ out, err := json.Marshal(obj)
64
+ if err != nil {
65
+ return text, false
66
+ }
67
+ return string(out), true
68
+ }
69
+
70
+ // stripJSONFence 去掉 markdown 的 ```json ... ``` 围栏(首尾)。
71
+ func stripJSONFence(s string) string {
72
+ if !strings.HasPrefix(s, "```") {
73
+ return s
74
+ }
75
+ if idx := strings.Index(s, "\n"); idx > 0 {
76
+ s = s[idx+1:]
77
+ }
78
+ if end := strings.LastIndex(s, "```"); end > 0 {
79
+ s = s[:end]
80
+ }
81
+ return strings.TrimSpace(s)
82
+ }
83
+
84
+ // firstJSONObject 在 s 中找首个「完整」的顶层 JSON 对象,并解为 map。
85
+ // 括号计数法(支持嵌套)。找到返回 (map, true);未找到 (nil, false)。
86
+ func firstJSONObject(s string) (map[string]any, bool) {
87
+ start := strings.IndexByte(s, '{')
88
+ if start < 0 {
89
+ return nil, false
90
+ }
91
+ depth, inStr, escape := 0, false, false
92
+ for i := start; i < len(s); i++ {
93
+ c := s[i]
94
+ if inStr {
95
+ switch c {
96
+ case '\\':
97
+ escape = !escape
98
+ continue
99
+ case '"':
100
+ if !escape {
101
+ inStr = false
102
+ }
103
+ escape = false
104
+ continue
105
+ default:
106
+ escape = false
107
+ continue
108
+ }
109
+ }
110
+ switch c {
111
+ case '"':
112
+ inStr = true
113
+ case '{':
114
+ depth++
115
+ case '}':
116
+ depth--
117
+ if depth == 0 {
118
+ candidate := s[start : i+1]
119
+ var obj map[string]any
120
+ if err := json.Unmarshal([]byte(candidate), &obj); err != nil {
121
+ return nil, false
122
+ }
123
+ return obj, true
124
+ }
125
+ }
126
+ }
127
+ return nil, false
128
+ }
@@ -0,0 +1,110 @@
1
+ package agent
2
+
3
+ // jsonextract_test.go - extractJSONObjectStrict 单元测试。
4
+ //
5
+ // 覆盖:
6
+ // - 普通 JSON 对象能抽取并紧凑化
7
+ // - 顶层缺 Required 字段 → 不改写
8
+ // - 围栏 / 前缀废话 → 剥掉
9
+ // - schema 为 nil / type 非 object / Required 为空 → 直接不动
10
+ // - 嵌套对象包了 Required 字段 → 不通过(要的就是扁平)
11
+
12
+ import (
13
+ "encoding/json"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // schemaRequired 构造只有 Required 的最小 schema(Properties 留空,不参与判定)。
19
+ func schemaRequired(required ...string) *JSONSchema {
20
+ return &JSONSchema{Type: "object", Required: required}
21
+ }
22
+
23
+ func TestExtractJSONObjectStrict(t *testing.T) {
24
+ t.Run("plain object extract", func(t *testing.T) {
25
+ raw := `{"setting":12,"character":12,"issues":[]}`
26
+ got, ok := extractJSONObjectStrict(raw, schemaRequired("setting", "character", "issues"))
27
+ if !ok {
28
+ t.Fatal("ok=false on plain JSON")
29
+ }
30
+ // 应能紧凑 marshal 回 round-trip
31
+ var back map[string]any
32
+ if err := json.Unmarshal([]byte(got), &back); err != nil {
33
+ t.Fatalf("not valid JSON after extract: %v (got=%q)", err, got)
34
+ }
35
+ for _, k := range []string{"setting", "character", "issues"} {
36
+ if _, ok := back[k]; !ok {
37
+ t.Errorf("missing %q in extracted: %s", k, got)
38
+ }
39
+ }
40
+ })
41
+
42
+ t.Run("with markdown fence and prefix text", func(t *testing.T) {
43
+ raw := "Here is the result:\n```json\n{\"a\":1,\"b\":2}\n```"
44
+ got, ok := extractJSONObjectStrict(raw, schemaRequired("a", "b"))
45
+ if !ok {
46
+ t.Fatal("ok=false on fenced JSON")
47
+ }
48
+ var back map[string]any
49
+ if err := json.Unmarshal([]byte(got), &back); err != nil {
50
+ t.Fatalf("invalid: %v (got=%q)", err, got)
51
+ }
52
+ })
53
+
54
+ t.Run("missing required field rejects", func(t *testing.T) {
55
+ raw := `{"setting":12}`
56
+ got, ok := extractJSONObjectStrict(raw, schemaRequired("setting", "character"))
57
+ if ok {
58
+ t.Errorf("expected reject for missing required field, got ok=true text=%q", got)
59
+ }
60
+ })
61
+
62
+ t.Run("nested object with required key inside doesn't count", func(t *testing.T) {
63
+ raw := `{"scores":{"setting":12,"character":12}}` // Required 在嵌套层
64
+ got, ok := extractJSONObjectStrict(raw, schemaRequired("setting"))
65
+ if ok {
66
+ t.Errorf("nested field should not satisfy top-level Required, got ok=true text=%q", got)
67
+ }
68
+ })
69
+
70
+ t.Run("nil schema does nothing", func(t *testing.T) {
71
+ raw := `{"a":1}`
72
+ if got, ok := extractJSONObjectStrict(raw, nil); ok || got != raw {
73
+ t.Errorf("nil schema should not transform: got=%q ok=%v", got, ok)
74
+ }
75
+ })
76
+
77
+ t.Run("non-object schema type does nothing", func(t *testing.T) {
78
+ raw := `[1,2,3]`
79
+ s := &JSONSchema{Type: "array"}
80
+ if got, ok := extractJSONObjectStrict(raw, s); ok || got != raw {
81
+ t.Errorf("non-object schema should not transform: got=%q ok=%v", got, ok)
82
+ }
83
+ })
84
+
85
+ t.Run("empty Required does nothing (upstream parser handles leniently)", func(t *testing.T) {
86
+ raw := `{"a":1}`
87
+ s := &JSONSchema{Type: "object"} // Required == nil
88
+ if got, ok := extractJSONObjectStrict(raw, s); ok || got != raw {
89
+ t.Errorf("empty Required should not transform: got=%q ok=%v", got, ok)
90
+ }
91
+ })
92
+
93
+ t.Run("object with unicode keys and nested arrays", func(t *testing.T) {
94
+ raw := `{"setting":12,"issues":["a","b"],"suggestions":["x"]}`
95
+ got, ok := extractJSONObjectStrict(raw, schemaRequired("setting", "issues", "suggestions"))
96
+ if !ok {
97
+ t.Fatal("ok=false")
98
+ }
99
+ if !strings.Contains(got, `"issues":["a","b"]`) {
100
+ t.Errorf("nested array should be preserved: %s", got)
101
+ }
102
+ })
103
+
104
+ t.Run("no JSON object at all", func(t *testing.T) {
105
+ raw := "just some plain text answer"
106
+ if got, ok := extractJSONObjectStrict(raw, schemaRequired("a")); ok || got != raw {
107
+ t.Errorf("expected reject for no JSON: got=%q ok=%v", got, ok)
108
+ }
109
+ })
110
+ }
@@ -0,0 +1,216 @@
1
+ package agent
2
+
3
+ // keepalive_test.go - 常驻会话(Request.Append)在 claude/codebuddy 上的落地。
4
+ //
5
+ // 用户需求(2026-09-18):「按路线 2 实现,先支持 claude 和 codebuddy 的追加功能」。
6
+ //
7
+ // 做法:假 CLI 用 `while read line` 模拟「stdin 每来一行就当作一轮」,
8
+ // 把收到的每行长度回显成 result —— 于是可以断言:
9
+ // - 追加的消息**真的进了子进程的 stdin**(行数 = 1 + 追加数);
10
+ // - 每轮都发 KindTurnEnd(CLI 的空闲收工挂在它上面);
11
+ // - 关闭追加通道 → 子进程 EOF 收尾 → Stream 正常返回(不需要 --stop)。
12
+
13
+ import (
14
+ "context"
15
+ "os"
16
+ "path/filepath"
17
+ "strings"
18
+ "testing"
19
+ "time"
20
+ )
21
+
22
+ // multiTurnCLI 假 CLI:stdin 每来一行就回一个 result,并把该行追加写进 seenFile。
23
+ func multiTurnCLI(t *testing.T, seenFile string) string {
24
+ t.Helper()
25
+ return writeFakeCLI(t, "claude", `#!/bin/sh
26
+ while IFS= read -r line; do
27
+ printf '%s\n' "$line" >> `+seenFile+`
28
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-ka","model":"m"}'
29
+ done
30
+ `)
31
+ }
32
+
33
+ // readLines 读 seenFile 的行数(文件可能还没建)。
34
+ func readLines(t *testing.T, p string) []string {
35
+ t.Helper()
36
+ data, err := os.ReadFile(p)
37
+ if err != nil {
38
+ return nil
39
+ }
40
+ s := strings.TrimSpace(string(data))
41
+ if s == "" {
42
+ return nil
43
+ }
44
+ return strings.Split(s, "\n")
45
+ }
46
+
47
+ func TestKeepAliveAppendsReachChildStdin(t *testing.T) {
48
+ seen := filepath.Join(t.TempDir(), "seen.txt")
49
+ cli := multiTurnCLI(t, seen)
50
+ e := &ClaudeEngine{BinPath: cli}
51
+
52
+ appendCh := make(chan string, 4)
53
+ events, onEvent := collectEvents()
54
+
55
+ type res struct {
56
+ out StreamResult
57
+ err error
58
+ }
59
+ done := make(chan res, 1)
60
+ go func() {
61
+ out, err := e.Stream(context.Background(), Request{
62
+ Messages: []Message{{Role: "user", Content: "首轮任务"}},
63
+ Append: appendCh,
64
+ }, onEvent)
65
+ done <- res{out, err}
66
+ }()
67
+
68
+ // 等首轮进了子进程
69
+ waitFor(t, func() bool { return len(readLines(t, seen)) >= 1 }, "首轮没进 stdin")
70
+ appendCh <- "追加需求 A"
71
+ waitFor(t, func() bool { return len(readLines(t, seen)) >= 2 }, "追加 A 没进 stdin")
72
+ appendCh <- "追加需求 B"
73
+ waitFor(t, func() bool { return len(readLines(t, seen)) >= 3 }, "追加 B 没进 stdin")
74
+
75
+ // 关闭通道 → 子进程 EOF → 优雅收尾
76
+ close(appendCh)
77
+
78
+ var got res
79
+ select {
80
+ case got = <-done:
81
+ case <-time.After(15 * time.Second):
82
+ t.Fatal("关闭追加通道后 Stream 没返回(常驻会话没能优雅收尾)")
83
+ }
84
+ if got.err != nil {
85
+ t.Fatalf("Stream: %v", got.err)
86
+ }
87
+ if got.out.Text != "ok" {
88
+ t.Errorf("Text = %q", got.out.Text)
89
+ }
90
+
91
+ lines := readLines(t, seen)
92
+ if len(lines) != 3 {
93
+ t.Fatalf("子进程收到 %d 行 want 3(首轮 + 两条追加)", len(lines))
94
+ }
95
+ if !strings.Contains(lines[0], "首轮任务") {
96
+ t.Errorf("第 1 行应是首轮提示词: %q", lines[0])
97
+ }
98
+ if !strings.Contains(lines[1], "追加需求 A") || !strings.Contains(lines[2], "追加需求 B") {
99
+ t.Errorf("追加内容没按顺序进 stdin: %v", lines)
100
+ }
101
+ // 追加是**流式输入**:每行都应是 user 消息 JSON
102
+ for i, l := range lines {
103
+ if !strings.Contains(l, `"type":"user"`) || !strings.Contains(l, `"role":"user"`) {
104
+ t.Errorf("第 %d 行不是 user 消息 JSON: %q", i+1, l)
105
+ }
106
+ }
107
+
108
+ // 每轮一个 KindTurnEnd(CLI 的空闲收工 / 应用侧「本轮完成」都靠它),且必须带会话 id
109
+ turnEnds := 0
110
+ for _, ev := range *events {
111
+ if ev.Kind == KindTurnEnd {
112
+ turnEnds++
113
+ if ev.SessionID != "s-ka" {
114
+ t.Errorf("turn_end 应带会话 id(应用侧靠它绑锚点),got %q", ev.SessionID)
115
+ }
116
+ if ev.Text == "" {
117
+ t.Error("turn_end 应带该轮正文")
118
+ }
119
+ }
120
+ }
121
+ if turnEnds != 3 {
122
+ t.Errorf("KindTurnEnd 事件数 = %d want 3(每轮一个)", turnEnds)
123
+ }
124
+ }
125
+
126
+ // codebuddy 同族,走同一条 stream-json 输入通道。
127
+ func TestKeepAliveWorksForCodeBuddy(t *testing.T) {
128
+ seen := filepath.Join(t.TempDir(), "seen.txt")
129
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
130
+ while IFS= read -r line; do
131
+ printf '%s\n' "$line" >> `+seen+`
132
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-ka","model":"m"}'
133
+ done
134
+ `)
135
+ e := &CodeBuddyEngine{BinPath: cli}
136
+ appendCh := make(chan string, 2)
137
+
138
+ done := make(chan error, 1)
139
+ go func() {
140
+ _, err := e.Stream(context.Background(), Request{
141
+ Messages: []Message{{Role: "user", Content: "首轮"}},
142
+ Append: appendCh,
143
+ }, nil)
144
+ done <- err
145
+ }()
146
+ waitFor(t, func() bool { return len(readLines(t, seen)) >= 1 }, "首轮没进 stdin")
147
+ appendCh <- "追加到 codebuddy"
148
+ waitFor(t, func() bool { return len(readLines(t, seen)) >= 2 }, "追加没进 stdin")
149
+ close(appendCh)
150
+
151
+ select {
152
+ case err := <-done:
153
+ if err != nil {
154
+ t.Fatalf("Stream: %v", err)
155
+ }
156
+ case <-time.After(15 * time.Second):
157
+ t.Fatal("codebuddy 常驻会话没优雅收尾")
158
+ }
159
+ if lines := readLines(t, seen); !strings.Contains(strings.Join(lines, "\n"), "追加到 codebuddy") {
160
+ t.Errorf("追加内容没进 codebuddy 的 stdin: %v", lines)
161
+ }
162
+ }
163
+
164
+ func TestAppendSupportOf(t *testing.T) {
165
+ for engine, want := range map[string]bool{
166
+ "claude": true,
167
+ "codebuddy": true,
168
+ "codebuddy-ai": true,
169
+ // dsh:SDK 通道下对同一 sessionId 继续 session/prompt 即续接同一会话
170
+ //(官方 Python SDK 文档「reuse a harness, home, and id」)。
171
+ "dsh": true,
172
+ "trae": false,
173
+ "codex": false,
174
+ "llm": false,
175
+ "openclaw": false,
176
+ "arkclaw": false,
177
+ "unknown": false,
178
+ } {
179
+ if got := AppendSupportOf(engine); got != want {
180
+ t.Errorf("AppendSupportOf(%q) = %v want %v", engine, got, want)
181
+ }
182
+ }
183
+ }
184
+
185
+ // 常驻会话的**默认值**按引擎区分:claude/codebuddy 默认开,dsh 默认关(要显式 --keep-alive)。
186
+ func TestAppendDefaultOn(t *testing.T) {
187
+ for engine, want := range map[string]bool{
188
+ "claude": true,
189
+ "codebuddy": true,
190
+ "codebuddy-ai": true,
191
+ "dsh": false, // 显式 --keep-alive 才开
192
+ "trae": false,
193
+ "unknown": false,
194
+ } {
195
+ if got := AppendDefaultOn(engine); got != want {
196
+ t.Errorf("AppendDefaultOn(%q) = %v want %v", engine, got, want)
197
+ }
198
+ }
199
+ // 默认关的引擎也必须「支持」(否则显式 --keep-alive 会被能力表挡下)。
200
+ if !AppendSupportOf("dsh") || AppendDefaultOn("dsh") {
201
+ t.Error("dsh 应是「支持但默认关」")
202
+ }
203
+ }
204
+
205
+ // waitFor 轮询等待条件成立(最多 10s)。
206
+ func waitFor(t *testing.T, cond func() bool, msg string) {
207
+ t.Helper()
208
+ deadline := time.Now().Add(10 * time.Second)
209
+ for time.Now().Before(deadline) {
210
+ if cond() {
211
+ return
212
+ }
213
+ time.Sleep(50 * time.Millisecond)
214
+ }
215
+ t.Fatalf("超时:%s", msg)
216
+ }