magic-agent 2.1.5 → 2.3.2
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 +147 -11
- package/README.md +10 -4
- package/client-ui/pc/app.css +17 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +24 -2
- package/client-ui/shared/conv/03-chat-message.js +63 -3
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +33 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +801 -102
- package/client-ui/shared/transport-http.js +199 -38
- package/client-ui-mobile/index.html +731 -581
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +520 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +14 -0
- package/internal/agent/llmengine_test.go +24 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +48 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +38 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +35 -3
- package/internal/client/service_test.go +35 -0
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +532 -0
- package/internal/suggest/suggest_test.go +268 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- 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/package.json +6 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// suggest.go - `--suggest-followups <file|->`:一轮结束后生成「用户下一句」的快捷回复。
|
|
4
|
+
//
|
|
5
|
+
// 为什么要单独一条 CLI(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,
|
|
6
|
+
// 点击即可发送」):界面(移动端 / PC)不该自己拼提示词、自己调模型 —— 那样每端一份,
|
|
7
|
+
// 同一轮回答在两处会给出不同建议。口径收进 core 之后只有一份(internal/suggest),
|
|
8
|
+
// 客户端(magic-client 的 /desk/suggest)exec 这一条即可。
|
|
9
|
+
//
|
|
10
|
+
// 与 `--summarize-title` 同一套旁路调用:独立进程、新会话、ToolsOff,不续接当前对话,
|
|
11
|
+
// 因此不会污染用户正在聊的那条上下文;也不写会话登记 / 日志(不经过 ask 主流程)。
|
|
12
|
+
//
|
|
13
|
+
// 退出码:
|
|
14
|
+
//
|
|
15
|
+
// 0 成功(哪怕建议为空数组 —— 「没有下一步」是合法结果,调用方不画按钮)
|
|
16
|
+
// 1 引擎失败(不可用 / 超时 / 报错)
|
|
17
|
+
// 2 参数错(请求 JSON 读不到 / 不合法 / 缺 reply)
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"fmt"
|
|
22
|
+
"io"
|
|
23
|
+
"os"
|
|
24
|
+
"strings"
|
|
25
|
+
|
|
26
|
+
"github.com/spf13/cobra"
|
|
27
|
+
|
|
28
|
+
"github.com/darren/magic-agent/internal/suggest"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// runSuggestFollowups 读取请求 JSON → 调 suggest.Generate → 打印 JSON 结果。
|
|
32
|
+
func runSuggestFollowups(cmd *cobra.Command, opts *askOptions) error {
|
|
33
|
+
body, err := readSuggestInput(cmd, opts.suggestInput)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return &usageError{err}
|
|
36
|
+
}
|
|
37
|
+
/* 引擎:没显式传 -e 时用 llm(纯文本、无工具、没有 CLI 会话副作用,与 --title-engine 同口径);
|
|
38
|
+
传了就以调用方的为准 —— 移动端把当前引擎带下来,用户的机器上一定装过它。 */
|
|
39
|
+
engine := suggest.DefaultEngine
|
|
40
|
+
if flagChanged(cmd, "engine") {
|
|
41
|
+
engine = opts.engine
|
|
42
|
+
}
|
|
43
|
+
model := ""
|
|
44
|
+
if flagChanged(cmd, "model") {
|
|
45
|
+
model = opts.model
|
|
46
|
+
}
|
|
47
|
+
res, err := suggest.Generate(cmd.Context(), suggest.Request{
|
|
48
|
+
User: body.User,
|
|
49
|
+
Reply: body.Reply,
|
|
50
|
+
Engine: engine,
|
|
51
|
+
Model: model,
|
|
52
|
+
Timeout: suggest.DefaultTimeout,
|
|
53
|
+
})
|
|
54
|
+
if err != nil {
|
|
55
|
+
return fmt.Errorf("--suggest-followups: 生成失败: %w", err)
|
|
56
|
+
}
|
|
57
|
+
if res.Suggestions == nil {
|
|
58
|
+
res.Suggestions = []string{}
|
|
59
|
+
}
|
|
60
|
+
return printJSON(cmd.OutOrStdout(), res)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// readSuggestInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file` 写法)。
|
|
64
|
+
// 上限 1MB:建议只该带「这一轮」的正文,超了说明调用方把整条会话塞进来了。
|
|
65
|
+
func readSuggestInput(cmd *cobra.Command, spec string) (suggest.RequestBody, error) {
|
|
66
|
+
var body suggest.RequestBody
|
|
67
|
+
src := strings.TrimSpace(spec)
|
|
68
|
+
switch src {
|
|
69
|
+
case "":
|
|
70
|
+
return body, fmt.Errorf("--suggest-followups 需要请求 JSON:传文件路径或 \"-\"(stdin)")
|
|
71
|
+
case "-":
|
|
72
|
+
b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
|
73
|
+
if err != nil {
|
|
74
|
+
return body, fmt.Errorf("读 stdin 失败:%w", err)
|
|
75
|
+
}
|
|
76
|
+
if err := json.Unmarshal(b, &body); err != nil {
|
|
77
|
+
return body, fmt.Errorf("stdin 不是合法 JSON:%w", err)
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
path := strings.TrimPrefix(src, "@")
|
|
81
|
+
if strings.HasPrefix(path, "~/") {
|
|
82
|
+
if home, herr := os.UserHomeDir(); herr == nil {
|
|
83
|
+
path = home + path[1:]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
b, err := os.ReadFile(path)
|
|
87
|
+
if err != nil {
|
|
88
|
+
return body, fmt.Errorf("读请求文件失败:%w", err)
|
|
89
|
+
}
|
|
90
|
+
if err := json.Unmarshal(b, &body); err != nil {
|
|
91
|
+
return body, fmt.Errorf("请求文件不是合法 JSON:%w", err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if strings.TrimSpace(body.Reply) == "" {
|
|
95
|
+
return body, fmt.Errorf(`请求 JSON 缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
|
|
96
|
+
}
|
|
97
|
+
return body, nil
|
|
98
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/darren/magic-agent/internal/suggest"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// TestSuggestFollowupsJSON 走完整 CLI:读 stdin 的请求 JSON → 假引擎 → 输出建议。
|
|
12
|
+
func TestSuggestFollowupsJSON(t *testing.T) {
|
|
13
|
+
registerFake(&stringEngine{
|
|
14
|
+
name: "fake-suggest-cli",
|
|
15
|
+
text: "这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```",
|
|
16
|
+
})
|
|
17
|
+
body := `{"user":"改完了吗","reply":"要我继续吗?"}`
|
|
18
|
+
out, errOut, err := runAskCmd(t, body, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatalf("suggest: %v(stderr=%s)", err, errOut)
|
|
21
|
+
}
|
|
22
|
+
var res suggest.Result
|
|
23
|
+
if uerr := json.Unmarshal([]byte(out), &res); uerr != nil {
|
|
24
|
+
t.Fatalf("输出不是 JSON:%v\n%s", uerr, out)
|
|
25
|
+
}
|
|
26
|
+
if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
|
|
27
|
+
t.Fatalf("Suggestions = %#v", res.Suggestions)
|
|
28
|
+
}
|
|
29
|
+
if res.Engine != "fake-suggest-cli" {
|
|
30
|
+
t.Fatalf("Engine = %q", res.Engine)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TestSuggestFollowupsEmptyReply 没有 reply 是**参数错**,不能当成空建议静默成功。
|
|
35
|
+
func TestSuggestFollowupsEmptyReply(t *testing.T) {
|
|
36
|
+
_, _, err := runAskCmd(t, `{"user":"只说了一句"}`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
37
|
+
if err == nil {
|
|
38
|
+
t.Fatal("缺 reply 应报参数错")
|
|
39
|
+
}
|
|
40
|
+
if !strings.Contains(err.Error(), "reply") {
|
|
41
|
+
t.Fatalf("报错没说清缺什么:%v", err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// TestSuggestFollowupsBadJSON 非法 JSON 也必须明确报错(exit 2 的语义)。
|
|
46
|
+
func TestSuggestFollowupsBadJSON(t *testing.T) {
|
|
47
|
+
_, _, err := runAskCmd(t, `not json`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
48
|
+
if err == nil {
|
|
49
|
+
t.Fatal("非法 JSON 应报参数错")
|
|
50
|
+
}
|
|
51
|
+
if _, ok := err.(*usageError); !ok {
|
|
52
|
+
t.Fatalf("应是 usageError(exit 2),实际 %T: %v", err, err)
|
|
53
|
+
}
|
|
54
|
+
}
|
package/internal/cli/team.go
CHANGED
|
@@ -17,6 +17,8 @@ import (
|
|
|
17
17
|
"encoding/json"
|
|
18
18
|
"fmt"
|
|
19
19
|
"strings"
|
|
20
|
+
"sync"
|
|
21
|
+
"time"
|
|
20
22
|
|
|
21
23
|
"github.com/spf13/cobra"
|
|
22
24
|
|
|
@@ -98,7 +100,50 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
98
100
|
// 事件出口:--no-thinking 只压思考。
|
|
99
101
|
// 团队模式下主会话只出现任务列表快照(KindTasks)与汇总,子任务的过程事件
|
|
100
102
|
// 走各自子会话的日志(见 team_session.go),不再带 parent 转发进来。
|
|
101
|
-
|
|
103
|
+
//
|
|
104
|
+
// ⚠️ 登记表的「还活着」靠这里续期(2026-10-22 报障:「团队模式子任务还在跑 主会话
|
|
105
|
+
// 就说结束了」)。流式路径(ask.go)对**每条事件**都写一次状态,团队路径此前只在
|
|
106
|
+
// 开跑前写一次 running —— 计划 + 子任务几分钟里 updated_at 一动不动,网关的尾随
|
|
107
|
+
// 连接(followStateEvery=5s)据 terminalSessionState 判「这一轮已结束」,画面落
|
|
108
|
+
// 红点收尾行,而子任务进程其实还在跑。团队模式没有 turn_end 可依,所以任何**主会话
|
|
109
|
+
// 自己发出的事件**(计划、任务列表快照、心跳、汇总)都当成一次「还在跑」。
|
|
110
|
+
// (h 在下面才建:闭包定义在这里不带值,第一次调用在 beginSession 之后。)
|
|
111
|
+
var emit func(agent.StreamEvent)
|
|
112
|
+
|
|
113
|
+
ctx := cmd.Context()
|
|
114
|
+
if !opts.keepAlive || flagChanged(cmd, "timeout") {
|
|
115
|
+
var cancel context.CancelFunc
|
|
116
|
+
ctx, cancel = context.WithTimeout(ctx, opts.timeout)
|
|
117
|
+
defer cancel()
|
|
118
|
+
}
|
|
119
|
+
// --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
|
|
120
|
+
var cancelAll context.CancelFunc
|
|
121
|
+
if opts.control {
|
|
122
|
+
ctx, cancelAll = context.WithCancel(ctx)
|
|
123
|
+
defer cancelAll()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
h := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
127
|
+
/* 团队模式**不挂** spawn hook:登记表里的 pid 必须一直是 magic-agent 自己。
|
|
128
|
+
团队路径会多次 spawn 主引擎(计划 / 汇总 / 补充规划),hook 会把 pid 换成
|
|
129
|
+
这些**短命**子进程的 pid;第一个(计划阶段)一退,normalizeDead 见 pid 不在
|
|
130
|
+
就判整条主会话 gone —— 而此刻子任务正在跑。真机复现:24 分钟的一轮里主会话
|
|
131
|
+
在第 32 秒就被判死,直到收尾才写回 done。
|
|
132
|
+
子引擎进程不靠这条登记收:它们各有自己的子会话记录(openSubSession),
|
|
133
|
+
异常退出时由 orphanSubs 按进程组收。 */
|
|
134
|
+
_ = withSessionHook
|
|
135
|
+
if h != nil {
|
|
136
|
+
// 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
|
|
137
|
+
h.SetMode(session.ModeTeam)
|
|
138
|
+
h.Mark(session.StateRunning)
|
|
139
|
+
}
|
|
140
|
+
emit = func(ev agent.StreamEvent) {
|
|
141
|
+
h.Mark(session.StateRunning)
|
|
142
|
+
/* 续期(2026-10-22 真机复现):Mark 在新旧状态相同时**不写盘**,所以上面那一句
|
|
143
|
+
对「已经 running」的会话等于空操作 —— updated_at 从第一条事件起就冻住了,
|
|
144
|
+
网关尾随连接据此判终态,画面在子任务还在跑时就落「这一轮已结束」。
|
|
145
|
+
Touch 才是真正的续期;5 秒节流对齐网关的 followStateEvery。 */
|
|
146
|
+
h.Touch(5 * time.Second)
|
|
102
147
|
if ev.Kind == agent.KindThinking && opts.noThinking {
|
|
103
148
|
return
|
|
104
149
|
}
|
|
@@ -136,27 +181,6 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
136
181
|
})
|
|
137
182
|
}
|
|
138
183
|
}
|
|
139
|
-
|
|
140
|
-
ctx := cmd.Context()
|
|
141
|
-
if !opts.keepAlive || flagChanged(cmd, "timeout") {
|
|
142
|
-
var cancel context.CancelFunc
|
|
143
|
-
ctx, cancel = context.WithTimeout(ctx, opts.timeout)
|
|
144
|
-
defer cancel()
|
|
145
|
-
}
|
|
146
|
-
// --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
|
|
147
|
-
var cancelAll context.CancelFunc
|
|
148
|
-
if opts.control {
|
|
149
|
-
ctx, cancelAll = context.WithCancel(ctx)
|
|
150
|
-
defer cancelAll()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
h := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
154
|
-
ctx = withSessionHook(ctx, h)
|
|
155
|
-
if h != nil {
|
|
156
|
-
// 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
|
|
157
|
-
h.SetMode(session.ModeTeam)
|
|
158
|
-
h.Mark(session.StateRunning)
|
|
159
|
-
}
|
|
160
184
|
if sessionLog != nil && h != nil {
|
|
161
185
|
sessionLog.setRunID(h.RunID())
|
|
162
186
|
}
|
|
@@ -180,6 +204,10 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
180
204
|
if h != nil {
|
|
181
205
|
runID = h.RunID()
|
|
182
206
|
}
|
|
207
|
+
/* 本次运行开出来的子会话(按 pid 收进程组用,见 orphanSubs)。
|
|
208
|
+
写它的是跑子任务的 goroutine,读的是异常退出那一支 —— 加锁。 */
|
|
209
|
+
var mu sync.Mutex
|
|
210
|
+
var subs []*subSession
|
|
183
211
|
res, err := team.Run(ctx, team.Options{
|
|
184
212
|
Engine: engine,
|
|
185
213
|
Model: req.Model,
|
|
@@ -204,13 +232,48 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
204
232
|
PlanOnly: opts.teamPlanOnly,
|
|
205
233
|
Adaptive: opts.teamAdaptive,
|
|
206
234
|
// 每个子任务 = 一条普通会话:登记记录 + 自己的日志,父指针挂在本会话下。
|
|
235
|
+
//
|
|
236
|
+
// 开出来的子会话都记进 subs:父进程异常退出时要按它们的 pid 收进程组
|
|
237
|
+
// (见下面的 orphan sweep),否则主会话没了、子引擎还在跑,列表里挂一条永远
|
|
238
|
+
// running 的子会话。
|
|
207
239
|
OpenSubSession: func(t team.Task, runDir, engine, model string) team.SubSession {
|
|
208
|
-
|
|
240
|
+
s := openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
|
|
241
|
+
if ss, ok := s.(*subSession); ok && ss != nil {
|
|
242
|
+
mu.Lock()
|
|
243
|
+
subs = append(subs, ss)
|
|
244
|
+
mu.Unlock()
|
|
245
|
+
}
|
|
246
|
+
return s
|
|
209
247
|
},
|
|
210
248
|
OnEvent: emit,
|
|
249
|
+
// 计划阶段不发事件的续期(见 team.Options.OnTick)。
|
|
250
|
+
OnTick: func() { h.Touch(0) },
|
|
211
251
|
})
|
|
252
|
+
mu.Lock()
|
|
253
|
+
running := append([]*subSession(nil), subs...)
|
|
254
|
+
mu.Unlock()
|
|
212
255
|
if err != nil {
|
|
213
|
-
|
|
256
|
+
/* 异常退出也要有**终态**(2026-10-22):团队模式没有 turn_end 可依,父进程失败时
|
|
257
|
+
一条终态事件都不发 —— 客户端只能看登记表,而登记表停在 running,网关按「还在跑」
|
|
258
|
+
一直等(或按内核被杀归一成 gone)。这里补一条 turn_failed:走的是流式路径
|
|
259
|
+
同一条收尾口径(turn_failed → 画面一行「本轮异常收尾」),调用方不必再猜。 */
|
|
260
|
+
interrupted := ctx.Err() != nil
|
|
261
|
+
reason := ""
|
|
262
|
+
if interrupted {
|
|
263
|
+
reason = "已中断(主进程被取消或被杀)"
|
|
264
|
+
} else {
|
|
265
|
+
reason = err.Error()
|
|
266
|
+
}
|
|
267
|
+
emit(agent.StreamEvent{Kind: agent.KindTurnFailed, Text: reason, Reason: reason})
|
|
268
|
+
state := session.StateFailed
|
|
269
|
+
if interrupted {
|
|
270
|
+
state = session.StateInterrupted
|
|
271
|
+
}
|
|
272
|
+
finishSession(h, "", state)
|
|
273
|
+
/* 子引擎是独立进程组(子会话上下文里挂的 SpawnHook 按组登记 pid):
|
|
274
|
+
父这一轮已经作废,把还在跑的一起收掉 —— 否则会话列表里永远挂着一条 running
|
|
275
|
+
的子会话,用户点进去只看到半截过程,而且它会一直烧额度。 */
|
|
276
|
+
orphanSubs(running)
|
|
214
277
|
if format == agent.FormatJSON {
|
|
215
278
|
_ = sink.event(streamErrorPayload(engine.Name(), err))
|
|
216
279
|
_ = agent.WriteError(stderr, format, engine.Name(), 1, err)
|
|
@@ -253,6 +316,30 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
253
316
|
return nil
|
|
254
317
|
}
|
|
255
318
|
|
|
319
|
+
// orphanSubs 收掉还没落定的子会话对应的子引擎进程组。
|
|
320
|
+
//
|
|
321
|
+
// 只在团队这一轮**异常退出**时调:正常收尾时每个子会话都在 runTask 的 end() 里
|
|
322
|
+
// Finish 过(进程已经自己退了)。这里兜的是「父进程这一轮作废、子引擎还在跑」那条路
|
|
323
|
+
// —— 不收就是孤儿进程:它一边烧额度,一边让会话列表里那条子会话永远 running。
|
|
324
|
+
//
|
|
325
|
+
// 幂等:进程已经不在(PIDAlive=false)就只补登记表状态,不再发信号。
|
|
326
|
+
func orphanSubs(list []*subSession) {
|
|
327
|
+
for _, s := range list {
|
|
328
|
+
if s == nil {
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
rec := s.h.Record()
|
|
332
|
+
if rec.PID > 0 && agent.PIDAlive(rec.PID) {
|
|
333
|
+
_ = agent.KillPID(rec.PID, rec.KillGroup)
|
|
334
|
+
}
|
|
335
|
+
s.h.Finish(rec.SessionID, session.StateInterrupted)
|
|
336
|
+
if s.log != nil {
|
|
337
|
+
s.log.close()
|
|
338
|
+
s.log = nil
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
256
343
|
// agentRefs 把子任务结果按「类别 + 引擎 + 模型」归并成登记表里的 Agents。
|
|
257
344
|
//
|
|
258
345
|
// 为什么按类别归并而不是一个任务一条:登记表是列表页的数据源,一个 6 任务的团队会话
|
|
@@ -21,6 +21,7 @@ package client
|
|
|
21
21
|
// 「一句说完、下一句还没打完」的经验值,期间再来一轮就顺延、合并成一次。
|
|
22
22
|
|
|
23
23
|
import (
|
|
24
|
+
"bytes"
|
|
24
25
|
"context"
|
|
25
26
|
"encoding/json"
|
|
26
27
|
"errors"
|
|
@@ -82,12 +83,13 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
|
|
|
82
83
|
// handleRequirementsConsolidate POST /desk/requirements/consolidate:
|
|
83
84
|
// 立即归纳一轮(用户点「整理」)。
|
|
84
85
|
//
|
|
85
|
-
// 请求体:{"workspace":"<ws_id|目录>", "
|
|
86
|
+
// 请求体:{"workspace":"<ws_id|目录>", "module":"<模块名>", "force":false,
|
|
86
87
|
//
|
|
87
|
-
// "engine":"claude", "model":"..."}
|
|
88
|
+
// "all":false, "engine":"claude", "model":"..."}
|
|
88
89
|
//
|
|
89
90
|
// workspace 留空 = 插件自己的工作目录(与 /desk/health 的 defaultWorkspace 一致 ——
|
|
90
91
|
// 那就是 core 不传 -w 时记录的 cwd)。
|
|
92
|
+
// module 留空 = 整理整个空间;给了就只整理那一个模块(移动端二级页的「整理」)。
|
|
91
93
|
// engine / model 是客户端当前配置的默认引擎与模型(移动端「我的 → 默认引擎」):
|
|
92
94
|
// 「整理的引擎就是配置的默认引擎」—— 客户端传什么,core 就按什么跑,不再自动替换。
|
|
93
95
|
func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.Request) {
|
|
@@ -102,6 +104,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
102
104
|
}
|
|
103
105
|
var body struct {
|
|
104
106
|
Workspace string `json:"workspace,omitempty"`
|
|
107
|
+
Module string `json:"module,omitempty"`
|
|
105
108
|
Force bool `json:"force,omitempty"`
|
|
106
109
|
All bool `json:"all,omitempty"`
|
|
107
110
|
Engine string `json:"engine,omitempty"`
|
|
@@ -122,6 +125,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
122
125
|
writeErr(w, http.StatusBadRequest, "workspace 不能为空(传 ws id 或目录)")
|
|
123
126
|
return
|
|
124
127
|
}
|
|
128
|
+
module := strings.TrimSpace(body.Module)
|
|
125
129
|
engine := strings.TrimSpace(body.Engine)
|
|
126
130
|
model := strings.TrimSpace(body.Model)
|
|
127
131
|
if model != "" && engine == "" {
|
|
@@ -133,7 +137,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
133
137
|
return
|
|
134
138
|
}
|
|
135
139
|
out, err := s.runCoreTimed(r.Context(), reqConsolidateTimeout,
|
|
136
|
-
consolidateArgs(ws, body.Force, body.All, false, engine, model)...)
|
|
140
|
+
consolidateArgs(ws, module, body.Force, body.All, false, engine, model)...)
|
|
137
141
|
if err != nil {
|
|
138
142
|
code := http.StatusBadGateway
|
|
139
143
|
if isCoreTimeout(err) {
|
|
@@ -150,8 +154,11 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
150
154
|
// engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
|
|
151
155
|
// 自动归纳(auto=true)不传:后台没有「当前设备默认引擎」这个概念,让 core 走自己的
|
|
152
156
|
// 默认(增量用 llm;首次初始化也不会发生,因为 --requirement-auto 明确禁止)。
|
|
153
|
-
func consolidateArgs(ws string, force, all, auto bool, engine, model string) []string {
|
|
157
|
+
func consolidateArgs(ws, module string, force, all, auto bool, engine, model string) []string {
|
|
154
158
|
args := []string{"--consolidate-requirements", ws}
|
|
159
|
+
if module != "" {
|
|
160
|
+
args = append(args, "--requirement-module", module)
|
|
161
|
+
}
|
|
155
162
|
if force {
|
|
156
163
|
args = append(args, "--requirement-force")
|
|
157
164
|
}
|
|
@@ -178,14 +185,27 @@ func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args
|
|
|
178
185
|
cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
|
|
179
186
|
// 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
|
|
180
187
|
cmd.WaitDelay = 5 * time.Second
|
|
181
|
-
|
|
188
|
+
var stdout, stderr bytes.Buffer
|
|
189
|
+
cmd.Stdout = &stdout
|
|
190
|
+
cmd.Stderr = &stderr
|
|
191
|
+
err := cmd.Run()
|
|
182
192
|
if err != nil {
|
|
183
193
|
if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
|
|
184
194
|
return nil, fmt.Errorf("%w(%s)", errCoreTimeout, timeout)
|
|
185
195
|
}
|
|
196
|
+
/* core 失败时也会先把结构化结果写到 stdout(`{ok:false,error:"..."}`),
|
|
197
|
+
再返回非零退出码。Output() 会把它丢掉,界面只剩一句「exit status 1」——
|
|
198
|
+
用户看不到真正原因(例如「模块不存在」)。这里把 stdout 交回调用方,
|
|
199
|
+
由它按 JSON 里的 error 展示;真的一个字都没输出才退化成 stderr/退出码。 */
|
|
200
|
+
if stdout.Len() > 0 {
|
|
201
|
+
return stdout.Bytes(), nil
|
|
202
|
+
}
|
|
203
|
+
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
|
204
|
+
return nil, fmt.Errorf("%s", msg)
|
|
205
|
+
}
|
|
186
206
|
return nil, err
|
|
187
207
|
}
|
|
188
|
-
return
|
|
208
|
+
return stdout.Bytes(), nil
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
// isCoreTimeout 判断错误链里是不是「等 core 超时」。
|
|
@@ -270,7 +290,7 @@ func (s *Service) runAutoConsolidate(ws string) {
|
|
|
270
290
|
|
|
271
291
|
// 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
|
|
272
292
|
ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
|
|
273
|
-
out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, false, false, true, "", "")...)
|
|
293
|
+
out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
|
|
274
294
|
cancel()
|
|
275
295
|
if err != nil {
|
|
276
296
|
s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
|
|
@@ -131,6 +131,23 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
|
|
|
131
131
|
if !ok {
|
|
132
132
|
t.Errorf("应透传目录 / force / 配置的默认引擎与模型,实际 %v", readCalls(t, logPath))
|
|
133
133
|
}
|
|
134
|
+
// 二级页「整理」:module 要原样透传给 core,否则会退化成整空间。
|
|
135
|
+
recMod := httptest.NewRecorder()
|
|
136
|
+
bodyMod := strings.NewReader(`{"workspace":"/proj","module":"登录","engine":"claude"}`)
|
|
137
|
+
svc.Handler().ServeHTTP(recMod, httptest.NewRequest(http.MethodPost,
|
|
138
|
+
"/desk/requirements/consolidate?token=tok", bodyMod))
|
|
139
|
+
if recMod.Code != http.StatusOK {
|
|
140
|
+
t.Fatalf("模块级归纳应 200,实际 %d: %s", recMod.Code, recMod.Body.String())
|
|
141
|
+
}
|
|
142
|
+
var sawModule bool
|
|
143
|
+
for _, c := range readCalls(t, logPath) {
|
|
144
|
+
if strings.Contains(c, "--consolidate-requirements /proj --requirement-module 登录") {
|
|
145
|
+
sawModule = true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if !sawModule {
|
|
149
|
+
t.Errorf("应透传 --requirement-module 登录,实际 %v", readCalls(t, logPath))
|
|
150
|
+
}
|
|
134
151
|
// 归纳的结果要能解出核心字段(客户端靠它显示「更新/新建/待整理」)。
|
|
135
152
|
var res struct {
|
|
136
153
|
OK bool `json:"ok"`
|
|
@@ -141,6 +158,40 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
|
|
|
141
158
|
}
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
// TestRequirementsConsolidateSurfacesCoreError core 失败时先写 JSON 再 exit 1,
|
|
162
|
+
// 插件不能把它吞成「exit status 1」——界面要能显示「模块不存在」这类真正原因。
|
|
163
|
+
func TestRequirementsConsolidateSurfacesCoreError(t *testing.T) {
|
|
164
|
+
dir := t.TempDir()
|
|
165
|
+
core := filepath.Join(dir, "magic-agent")
|
|
166
|
+
script := `#!/bin/sh
|
|
167
|
+
case "$*" in
|
|
168
|
+
*--contract*)
|
|
169
|
+
echo '{"contractVersion":2,"features":["feature.session.events","feature.session.control","` + FeatureRequirements + `"],"engines":[]}'
|
|
170
|
+
exit 0
|
|
171
|
+
;;
|
|
172
|
+
*--consolidate-requirements*)
|
|
173
|
+
echo '{"ok":false,"workspace":"ws-1","module":"不存在","error":"模块不存在: 不存在"}'
|
|
174
|
+
exit 1
|
|
175
|
+
;;
|
|
176
|
+
esac
|
|
177
|
+
exit 0
|
|
178
|
+
`
|
|
179
|
+
if err := os.WriteFile(core, []byte(script), 0o755); err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
svc := newTestService(t, core)
|
|
183
|
+
rec := httptest.NewRecorder()
|
|
184
|
+
body := strings.NewReader(`{"workspace":"ws-1","module":"不存在"}`)
|
|
185
|
+
svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
|
|
186
|
+
"/desk/requirements/consolidate?token=tok", body))
|
|
187
|
+
if rec.Code != http.StatusOK {
|
|
188
|
+
t.Fatalf("core 的结构化失败应原样回传,实际 %d: %s", rec.Code, rec.Body.String())
|
|
189
|
+
}
|
|
190
|
+
if !strings.Contains(rec.Body.String(), "模块不存在") {
|
|
191
|
+
t.Errorf("应保留 core 的 error 文案,实际 %s", rec.Body.String())
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
144
195
|
func TestMaybeAutoConsolidate(t *testing.T) {
|
|
145
196
|
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
146
197
|
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
@@ -49,6 +49,13 @@ const RequiredContractVersion = 2
|
|
|
49
49
|
// 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
|
|
50
50
|
var requiredFeatures = []string{"feature.session.events", "feature.session.control"}
|
|
51
51
|
|
|
52
|
+
// FeatureSessionHosted core 的「会话托管历史标识」能力位(与 internal/agent 同值)。
|
|
53
|
+
//
|
|
54
|
+
// 可选特性:老 core 没有它,插件照常发送,只是不追加 --hosted ——
|
|
55
|
+
// 移动端实时气泡仍由客户端自己标,历史回放则退回「用户亲手发的」画法。
|
|
56
|
+
// 不塞进 requiredFeatures,否则升插件就得同步升 core。
|
|
57
|
+
const FeatureSessionHosted = "feature.session.hosted"
|
|
58
|
+
|
|
52
59
|
// Options 启动参数。
|
|
53
60
|
type Options struct {
|
|
54
61
|
// MagicAgent core 可执行文件;空 = 从 PATH 找 magic-agent。
|
|
@@ -221,6 +228,9 @@ func (s *Service) Handler() http.Handler {
|
|
|
221
228
|
// 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
|
|
222
229
|
mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
|
|
223
230
|
mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
|
|
231
|
+
// 下一轮快捷回复(2026-10-23):一轮结束后生成 0-3 条「用户下一句」。
|
|
232
|
+
// 旁路调用 core 的 --suggest-followups(不续接会话),界面点击即发。
|
|
233
|
+
mux.HandleFunc("/desk/suggest", s.guard(s.handleSuggest))
|
|
224
234
|
// 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
|
|
225
235
|
// 不再各存各的 localStorage(见 prefs.go)。
|
|
226
236
|
mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
|
|
@@ -1074,6 +1084,8 @@ func truncateForMsg(s string) string {
|
|
|
1074
1084
|
// 2026-10-19:实现挪进 `session.Delete`(internal/session/delete.go)—— CLI 新增的
|
|
1075
1085
|
// `--delete-session` 走的是同一份,两条路不再各写一遍(此前只有本接口能做真删除,
|
|
1076
1086
|
// 于是桌面端只能在自己本地藏起来,出现「PC 删了、移动端还在」)。
|
|
1087
|
+
// 2026-10-23:`session.Delete` 会沿父指针级联删掉团队主会话下的子任务会话,
|
|
1088
|
+
// 本接口因此也一并生效(响应多一个只增字段 childSessionsRemoved)。
|
|
1077
1089
|
func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
|
|
1078
1090
|
id, action, ok := parseSessionPath(r.URL.Path)
|
|
1079
1091
|
if !ok || action != "" {
|
|
@@ -1085,7 +1097,12 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
1085
1097
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
|
|
1086
1098
|
return
|
|
1087
1099
|
}
|
|
1088
|
-
|
|
1100
|
+
// 子会话计数只增字段(旧客户端忽略):告诉 UI「这次连子任务一起删了几条」。
|
|
1101
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
1102
|
+
"id": res.ID,
|
|
1103
|
+
"recordsRemoved": res.RecordsRemoved,
|
|
1104
|
+
"childSessionsRemoved": res.ChildSessionsRemoved,
|
|
1105
|
+
})
|
|
1089
1106
|
}
|
|
1090
1107
|
|
|
1091
1108
|
// parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
|
|
@@ -1773,6 +1790,12 @@ type askRequest struct {
|
|
|
1773
1790
|
// 方式,不是这台机器的属性。开关状态由客户端自己记(localStorage),每轮随 body
|
|
1774
1791
|
// 下发 —— core 因此完全无状态,「刚才那条为什么走了团队」在会话登记表里查得到。
|
|
1775
1792
|
Team *teamRequest `json:"team,omitempty"`
|
|
1793
|
+
// Hosted 本轮提问由客户端「会话托管」自动发送(2026-10-23,只增字段)。
|
|
1794
|
+
//
|
|
1795
|
+
// 只用于把 `kind:"user"` 历史事件标成 hosted(客户端回放画成「托管发送」),
|
|
1796
|
+
// 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
|
|
1797
|
+
// 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
|
|
1798
|
+
Hosted bool `json:"hosted,omitempty"`
|
|
1776
1799
|
}
|
|
1777
1800
|
|
|
1778
1801
|
// teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
|
|
@@ -1870,7 +1893,7 @@ const (
|
|
|
1870
1893
|
// 游标(handleAskEvents 里 `after == 0` 才放行它);
|
|
1871
1894
|
// - `prompt_id` 是客户端去重的键:core 首行到达前客户端会反复以 after=0 轮询,没有它
|
|
1872
1895
|
// 同一句提问会被画很多遍(见 client-ui/shared/conversation.js 的 render)。
|
|
1873
|
-
func (r *askRun) appendPrompt(text string, attachments []string) {
|
|
1896
|
+
func (r *askRun) appendPrompt(text string, attachments []string, hosted bool) {
|
|
1874
1897
|
atts := make([]map[string]string, 0, len(attachments))
|
|
1875
1898
|
for _, p := range attachments {
|
|
1876
1899
|
if p = strings.TrimSpace(p); p != "" {
|
|
@@ -1886,6 +1909,10 @@ func (r *askRun) appendPrompt(text string, attachments []string) {
|
|
|
1886
1909
|
if len(atts) > 0 {
|
|
1887
1910
|
ev["attachments"] = atts
|
|
1888
1911
|
}
|
|
1912
|
+
/* 与 core 落盘的同名字段同义:断线续传画回来的那条也要是「托管发送」。 */
|
|
1913
|
+
if hosted {
|
|
1914
|
+
ev["hosted"] = true
|
|
1915
|
+
}
|
|
1889
1916
|
b, err := json.Marshal(ev)
|
|
1890
1917
|
if err != nil {
|
|
1891
1918
|
return
|
|
@@ -2066,6 +2093,11 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
|
2066
2093
|
args = append(args, "-a", p)
|
|
2067
2094
|
}
|
|
2068
2095
|
args = append(args, "-p", req.Prompt)
|
|
2096
|
+
/* 托管标识:老 core 不认识 --hosted(exit 2),所以先看能力位;缺了就不传 ——
|
|
2097
|
+
发送本身不能因为这个**纯显示**字段被阻断(实时气泡由客户端自己标)。 */
|
|
2098
|
+
if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
|
|
2099
|
+
args = append(args, "--hosted")
|
|
2100
|
+
}
|
|
2069
2101
|
|
|
2070
2102
|
/* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
|
|
2071
2103
|
一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
|
|
@@ -2114,7 +2146,7 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
|
2114
2146
|
cmd.Stderr = run.stderr
|
|
2115
2147
|
/* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
|
|
2116
2148
|
断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
|
|
2117
|
-
run.appendPrompt(req.Prompt, req.Attachments)
|
|
2149
|
+
run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
|
|
2118
2150
|
if err := cmd.Start(); err != nil {
|
|
2119
2151
|
writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
|
|
2120
2152
|
return
|
|
@@ -66,6 +66,9 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
|
|
|
66
66
|
|
|
67
67
|
const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
|
|
68
68
|
|
|
69
|
+
// hostedContract 多了托管历史标识的能力位(2026-10-23 只增)。
|
|
70
|
+
const hostedContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control","feature.session.hosted"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
|
|
71
|
+
|
|
69
72
|
func newTestService(t *testing.T, core string) *Service {
|
|
70
73
|
t.Helper()
|
|
71
74
|
svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
|
|
@@ -418,6 +421,38 @@ func TestAskPassesSaveHistory(t *testing.T) {
|
|
|
418
421
|
}
|
|
419
422
|
}
|
|
420
423
|
|
|
424
|
+
// TestAskPassesHosted 托管自动发送的标识:core 有这个能力位时才透传 --hosted。
|
|
425
|
+
// 它只写会话历史(回放画成「托管发送」),执行语义不变。
|
|
426
|
+
func TestAskPassesHosted(t *testing.T) {
|
|
427
|
+
svc := newTestService(t, fakeCore(t, hostedContract))
|
|
428
|
+
srv := httptest.NewServer(svc.Handler())
|
|
429
|
+
defer srv.Close()
|
|
430
|
+
|
|
431
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
|
|
432
|
+
defer sse.close()
|
|
433
|
+
_ = sse.next(t)
|
|
434
|
+
raw, _ := sse.next(t)["raw"].(string)
|
|
435
|
+
if !strings.Contains(raw, "--hosted") {
|
|
436
|
+
t.Errorf("core 支持 feature.session.hosted 时应透传 --hosted,实际 %q", raw)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// TestAskDropsHostedForOldCore 老 core 不认识 --hosted(会 exit 2),插件必须忽略。
|
|
441
|
+
// 这是**纯显示**字段,不能因为它阻断发送;实时气泡仍由客户端自己标。
|
|
442
|
+
func TestAskDropsHostedForOldCore(t *testing.T) {
|
|
443
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
444
|
+
srv := httptest.NewServer(svc.Handler())
|
|
445
|
+
defer srv.Close()
|
|
446
|
+
|
|
447
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
|
|
448
|
+
defer sse.close()
|
|
449
|
+
_ = sse.next(t)
|
|
450
|
+
raw, _ := sse.next(t)["raw"].(string)
|
|
451
|
+
if strings.Contains(raw, "--hosted") {
|
|
452
|
+
t.Errorf("老 core 没有 feature.session.hosted,不该透传 --hosted,实际 %q", raw)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
421
456
|
func TestAskInjectsClientEnv(t *testing.T) {
|
|
422
457
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
423
458
|
srv := httptest.NewServer(svc.Handler())
|