magic-agent 1.0.2 → 1.0.46
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 +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- 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 +78 -2
- package/internal/agent/env_test.go +37 -0
- 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/repair_models_test.go +194 -0
- 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 +282 -51
- 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/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- 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 +78 -10
- package/npm/bin/magic-client.js +100 -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
package/internal/cli/session.go
CHANGED
|
@@ -14,11 +14,11 @@ package cli
|
|
|
14
14
|
|
|
15
15
|
import (
|
|
16
16
|
"encoding/json"
|
|
17
|
+
"errors"
|
|
17
18
|
"fmt"
|
|
18
19
|
"io"
|
|
19
20
|
"os"
|
|
20
21
|
"os/signal"
|
|
21
|
-
"path/filepath"
|
|
22
22
|
"strings"
|
|
23
23
|
"sync"
|
|
24
24
|
"syscall"
|
|
@@ -72,11 +72,18 @@ func stopActiveSession() {
|
|
|
72
72
|
// 为什么必须做:引擎 CLI 在**独立进程组**里(Setpgid),调用方对 magic-agent
|
|
73
73
|
// 发 SIGTERM / kill(-magic-agent-pid) 时,引擎子进程不在那一组里 → 会变成孤儿继续跑
|
|
74
74
|
// (观物台「停止」按钮踩的就是这个)。这里在退出前补一刀。
|
|
75
|
+
//
|
|
76
|
+
// 覆盖三处孤儿来源:
|
|
77
|
+
// - 当前活动会话(stopActiveSession);
|
|
78
|
+
// - 交互式登录子进程(stopLoginChild)—— 它必须待在前台组里读 tty,因而
|
|
79
|
+
// 比普通引擎更「自己管不了」,只能在这里记一笔带走(见 login.go);
|
|
80
|
+
// - 关终端(SIGHUP):与 SIGTERM 同路。
|
|
75
81
|
func installSignalStop() {
|
|
76
82
|
ch := make(chan os.Signal, 1)
|
|
77
|
-
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
|
83
|
+
signal.Notify(ch, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
|
78
84
|
go func() {
|
|
79
85
|
<-ch
|
|
86
|
+
stopLoginChild()
|
|
80
87
|
stopActiveSession()
|
|
81
88
|
os.Exit(exitInterrupted)
|
|
82
89
|
}()
|
|
@@ -99,17 +106,27 @@ type keepAlive struct {
|
|
|
99
106
|
closed bool // 收工后为 true:Push 据此拒收(与 close(in) 同锁,避免向已关闭通道发送)
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
// kaEnabled 常驻会话是否生效:只在「流式 +
|
|
103
|
-
//
|
|
104
|
-
// 里(那里能拿到 cmd 与引擎)。
|
|
109
|
+
// kaEnabled 常驻会话是否生效:只在「流式 + 引擎支持追加 + 本平台有追加入口」时才有意义
|
|
110
|
+
// —— 不满足就静默不启用(不影响原有调用形态)。显式传 `--keep-alive` 却不满足条件的
|
|
111
|
+
// 报错在 prepareAsk 里(那里能拿到 cmd 与引擎)。
|
|
105
112
|
//
|
|
106
113
|
// 默认值语义**按引擎区分**(agent.AppendDefaultOn):claude/codebuddy 默认常驻
|
|
107
114
|
// (用户 2026-09-18 的要求);dsh 默认关、要显式 `--keep-alive` —— 它的 SDK 通道本来
|
|
108
115
|
// 就是一次一轮的形态,默认挂 5 分钟空闲窗口会让既有调用方以为命令卡住了。
|
|
116
|
+
//
|
|
117
|
+
// ⚠️ 平台维度(2026-10-01):Windows 没有 unix domain socket,追加入口开不出来。
|
|
118
|
+
//
|
|
119
|
+
// 若仍按引擎默认值返回 true,用户**没要求**常驻却会撞上
|
|
120
|
+
// 「--keep-alive: 只支持 macOS/Linux」的报错 —— 默认值不该制造错误。
|
|
121
|
+
// 故这里按传输能力收敛:Windows 上默认值一律视为关;
|
|
122
|
+
// **显式** --keep-alive=true 仍由 prepareAsk 报错(用户明确要了,要说清为什么不行)。
|
|
109
123
|
func kaEnabled(cmd *cobra.Command, opts *askOptions, engine string) bool {
|
|
110
124
|
if !opts.stream || !agent.AppendSupportOf(engine) {
|
|
111
125
|
return false
|
|
112
126
|
}
|
|
127
|
+
if !session.AppendTransportAvailable() {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
113
130
|
if flagChanged(cmd, "keep-alive") {
|
|
114
131
|
return opts.keepAlive // 显式开关优先于引擎默认值
|
|
115
132
|
}
|
|
@@ -317,12 +334,28 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
|
|
|
317
334
|
PID int `json:"pid"`
|
|
318
335
|
Engine string `json:"engine"`
|
|
319
336
|
Model string `json:"model,omitempty"`
|
|
337
|
+
Source string `json:"source,omitempty"`
|
|
338
|
+
Client string `json:"client,omitempty"`
|
|
320
339
|
State string `json:"state"`
|
|
321
340
|
Alive bool `json:"alive"`
|
|
322
341
|
Workspace string `json:"workspace,omitempty"`
|
|
323
342
|
PromptHead string `json:"prompt_head,omitempty"`
|
|
324
|
-
|
|
325
|
-
|
|
343
|
+
// Title 这条会话的**可展示标题**:优先标题表里的归纳结果(--summarize-title 写的),
|
|
344
|
+
// 没有就回落 prompt_head。
|
|
345
|
+
//
|
|
346
|
+
// 为什么由 CLI 给(2026-10-15,用户:「应该 magic-agent 统一归纳作为会话标题」):
|
|
347
|
+
// 归纳口径收进 core 之后,标题就该由 core 给一个确定值 —— 客户端各自
|
|
348
|
+
// 「TITLES[id] || prompt_head」地拼,迟早在「哪份优先」上分叉(这正是此前
|
|
349
|
+
// PC 与移动端显示不同标题的原因)。这里给最终值,两端只渲染。
|
|
350
|
+
Title string `json:"title"`
|
|
351
|
+
// TitleSummarized 这个 title 是**真归纳出来的**,还是仅仅回落成了 prompt_head。
|
|
352
|
+
//
|
|
353
|
+
// 为什么必须单独给一个布尔:title 恒非空(回落也有值),所以客户端**无法**用
|
|
354
|
+
// 「title 为空」判断「还没归纳」—— 那样写出来的自动归纳会永远不触发(静默失效,
|
|
355
|
+
// 界面上只是标题一直显示首问原文,看不出是 bug)。客户端要补归纳就认这个字段。
|
|
356
|
+
TitleSummarized bool `json:"title_summarized"`
|
|
357
|
+
StartedAt string `json:"started_at"`
|
|
358
|
+
UpdatedAt string `json:"updated_at"`
|
|
326
359
|
// HasLog 这条会话有没有**可回放的事件日志**(`--session-log` 读的就是它)。
|
|
327
360
|
// 为什么让 CLI 来说这件事:日志**只在流式调用时写**(见 sessionlog.go 的 sessionWriter),
|
|
328
361
|
// 所以非流式跑出来的会话没有历史可回放 —— 客户端要拿它判断「这条点开有东西看吗」,
|
|
@@ -332,32 +365,43 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
|
|
|
332
365
|
logDir, _ := sessionLogDir()
|
|
333
366
|
rows := make([]row, 0, len(records))
|
|
334
367
|
for _, r := range records {
|
|
368
|
+
/* 标题的键可能是 session_id,也可能是 run_id(首轮归纳时引擎还没吐 id,见
|
|
369
|
+
session.TitleForAny)—— 两个都认,且**一次取定**,免得两个客户端因为
|
|
370
|
+
「谁先拿到 id」而显示不同标题。取不到才回落 prompt_head(= 还没归纳)。 */
|
|
371
|
+
title := session.TitleForAny(r.SessionID, r.RunID)
|
|
372
|
+
summarized := title != ""
|
|
373
|
+
if title == "" {
|
|
374
|
+
title = r.PromptHead
|
|
375
|
+
}
|
|
335
376
|
rows = append(rows, row{
|
|
336
|
-
RunID:
|
|
337
|
-
SessionID:
|
|
338
|
-
PID:
|
|
339
|
-
Engine:
|
|
340
|
-
Model:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
377
|
+
RunID: r.RunID,
|
|
378
|
+
SessionID: r.SessionID,
|
|
379
|
+
PID: r.PID,
|
|
380
|
+
Engine: r.Engine,
|
|
381
|
+
Model: r.Model,
|
|
382
|
+
Source: r.Source,
|
|
383
|
+
Client: r.Client,
|
|
384
|
+
State: r.State,
|
|
385
|
+
Alive: r.State == session.StateRunning,
|
|
386
|
+
Workspace: r.Workspace,
|
|
387
|
+
PromptHead: r.PromptHead,
|
|
388
|
+
Title: title,
|
|
389
|
+
TitleSummarized: summarized,
|
|
390
|
+
StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
|
|
391
|
+
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
392
|
+
HasLog: hasSessionLog(logDir, r),
|
|
348
393
|
})
|
|
349
394
|
}
|
|
350
395
|
return printJSON(cmd.OutOrStdout(), rows)
|
|
351
396
|
}
|
|
352
397
|
|
|
353
|
-
// hasSessionLog
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return err == nil && st.Size() > 0
|
|
398
|
+
// hasSessionLog 这条记录有没有可回放的事件日志。
|
|
399
|
+
//
|
|
400
|
+
// 日志文件名可能是 **session_id**(终名),也可能是 **run_id** —— 引擎还没吐 id 时
|
|
401
|
+
// core 先按 run_id 落盘(见 sessionlog.go 的 setRunID / renameTo),所以两个名字都试一次;
|
|
402
|
+
// 决议逻辑(含 run_id → session_id 的翻译)在 session.HasSessionLog 里,读接口共用同一份。
|
|
403
|
+
func hasSessionLog(dir string, rec session.Record) bool {
|
|
404
|
+
return session.HasSessionLog(dir, rec.SessionID) || session.HasSessionLog(dir, rec.RunID)
|
|
361
405
|
}
|
|
362
406
|
|
|
363
407
|
// runSessionLog 读一段会话的**事件历史**:`--session-log <id> [--after <seq>]`。
|
|
@@ -379,15 +423,76 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
|
|
|
379
423
|
if err != nil {
|
|
380
424
|
return fmt.Errorf("--session-log %s: 找不到历史目录: %w", id, err)
|
|
381
425
|
}
|
|
426
|
+
/* 传进来的可能是 run_id(登记表里的运行 id):日志文件名按 session_id 落,
|
|
427
|
+
所以先翻一次名 —— 否则「点开正在跑的会话」永远读到空历史(静默)。
|
|
428
|
+
见 session.ResolveLogID。翻译后的 id 也**照实回报**(输出的 session 字段),
|
|
429
|
+
客户端据此就能知道这条会话真正的 session_id。 */
|
|
430
|
+
id = session.ResolveLogID(dir, id)
|
|
431
|
+
|
|
432
|
+
// --after(向后追新)与 --before(向前翻旧)是两条相反的路:一次只能走一条,
|
|
433
|
+
// 同时给必然是调用方拼错了参数 —— 明说,不静默挑一个。
|
|
434
|
+
if flagChanged(cmd, "after") && flagChanged(cmd, "before") {
|
|
435
|
+
return &usageError{fmt.Errorf("--after 与 --before 互斥:--after 向后追新,--before 向前翻更早的历史,一次只能用一条")}
|
|
436
|
+
}
|
|
437
|
+
if flagChanged(cmd, "before") {
|
|
438
|
+
limit := opts.logLimit
|
|
439
|
+
if limit <= 0 {
|
|
440
|
+
limit = 200
|
|
441
|
+
}
|
|
442
|
+
var evs []sessionEvent
|
|
443
|
+
var nextBefore uint64
|
|
444
|
+
var more bool
|
|
445
|
+
var err error
|
|
446
|
+
if opts.logLean {
|
|
447
|
+
evs, nextBefore, more, err = ReadSessionLogBeforeLean(dir, id, opts.logBefore, limit)
|
|
448
|
+
} else {
|
|
449
|
+
evs, nextBefore, more, err = ReadSessionLogBefore(dir, id, opts.logBefore, limit)
|
|
450
|
+
}
|
|
451
|
+
if err != nil {
|
|
452
|
+
return fmt.Errorf("--session-log %s: %w", id, err)
|
|
453
|
+
}
|
|
454
|
+
if evs == nil {
|
|
455
|
+
evs = []sessionEvent{}
|
|
456
|
+
}
|
|
457
|
+
if opts.logLean {
|
|
458
|
+
stripSnapshots(evs) // 双保险,同 --after 分支
|
|
459
|
+
}
|
|
460
|
+
// 页头对齐(切到轮次起点的 user)与下一页游标都在 core 里算:被裁掉的几条
|
|
461
|
+
// 会由下一页补回,调用方拿 nextBefore 继续翻即可,不重不漏。
|
|
462
|
+
var firstSeq uint64
|
|
463
|
+
if len(evs) > 0 {
|
|
464
|
+
firstSeq = evs[0].Seq
|
|
465
|
+
}
|
|
466
|
+
return printJSON(cmd.OutOrStdout(), struct {
|
|
467
|
+
Session string `json:"session"`
|
|
468
|
+
Events []sessionEvent `json:"events"`
|
|
469
|
+
Count int `json:"count"`
|
|
470
|
+
Before uint64 `json:"before"`
|
|
471
|
+
NextBefore uint64 `json:"nextBefore"`
|
|
472
|
+
FirstSeq uint64 `json:"firstSeq"`
|
|
473
|
+
More bool `json:"more"`
|
|
474
|
+
}{Session: id, Events: evs, Count: len(evs), Before: opts.logBefore,
|
|
475
|
+
NextBefore: nextBefore, FirstSeq: firstSeq, More: more})
|
|
476
|
+
}
|
|
382
477
|
|
|
383
478
|
if flagChanged(cmd, "after") {
|
|
384
|
-
evs
|
|
479
|
+
var evs []sessionEvent
|
|
480
|
+
var maxSeq uint64
|
|
481
|
+
var err error
|
|
482
|
+
if opts.logLean {
|
|
483
|
+
evs, maxSeq, err = ReadSessionLogAfterLean(dir, id, opts.logAfter)
|
|
484
|
+
} else {
|
|
485
|
+
evs, maxSeq, err = ReadSessionLogAfter(dir, id, opts.logAfter)
|
|
486
|
+
}
|
|
385
487
|
if err != nil {
|
|
386
488
|
return fmt.Errorf("--session-log %s: %w", id, err)
|
|
387
489
|
}
|
|
388
490
|
if evs == nil {
|
|
389
491
|
evs = []sessionEvent{}
|
|
390
492
|
}
|
|
493
|
+
if opts.logLean {
|
|
494
|
+
stripSnapshots(evs) // 双保险:lean 读已摘过,这里只兜住未来的新调用方
|
|
495
|
+
}
|
|
391
496
|
return printJSON(cmd.OutOrStdout(), struct {
|
|
392
497
|
Session string `json:"session"`
|
|
393
498
|
Events []sessionEvent `json:"events"`
|
|
@@ -401,13 +506,21 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
|
|
|
401
506
|
LastSeq: maxSeq, SnapshotRequired: opts.logAfter > maxSeq})
|
|
402
507
|
}
|
|
403
508
|
|
|
404
|
-
evs
|
|
509
|
+
var evs []sessionEvent
|
|
510
|
+
if opts.logLean {
|
|
511
|
+
evs, err = ReadSessionLogLean(dir, id)
|
|
512
|
+
} else {
|
|
513
|
+
evs, err = ReadSessionLog(dir, id)
|
|
514
|
+
}
|
|
405
515
|
if err != nil {
|
|
406
516
|
return fmt.Errorf("--session-log %s: %w", id, err)
|
|
407
517
|
}
|
|
408
518
|
if evs == nil {
|
|
409
519
|
evs = []sessionEvent{}
|
|
410
520
|
}
|
|
521
|
+
if opts.logLean {
|
|
522
|
+
stripSnapshots(evs) // 双保险,同上面 --after 分支
|
|
523
|
+
}
|
|
411
524
|
return printJSON(cmd.OutOrStdout(), struct {
|
|
412
525
|
Session string `json:"session"`
|
|
413
526
|
Events []sessionEvent `json:"events"`
|
|
@@ -475,6 +588,47 @@ func stopText(res session.StopResult) string {
|
|
|
475
588
|
return fmt.Sprintf("magic-agent: 会话 %s 未在运行:%s(state=%s)", who, reason, res.Record.State)
|
|
476
589
|
}
|
|
477
590
|
|
|
591
|
+
// runDeleteSession 删除一条会话:登记记录 + 事件日志 + 标题覆盖(见 session.Delete)。
|
|
592
|
+
//
|
|
593
|
+
// 退出码:0 已删除 / 本来就不在(幂等);2 参数错(空 id / id 里带路径分隔符)。
|
|
594
|
+
//
|
|
595
|
+
// ⚠️ 这是**全机生效**的物理删除 —— 各端读的都是同一份登记表,一端删了处处都看不到。
|
|
596
|
+
// 客户端把它当成「删除」这个动作的唯一实现,别只在本地记墓碑(那正是
|
|
597
|
+
// 「从pc端删除的会话移动端还在显示」的原因)。
|
|
598
|
+
func runDeleteSession(cmd *cobra.Command, opts *askOptions) error {
|
|
599
|
+
id := strings.TrimSpace(opts.deleteSession)
|
|
600
|
+
if id == "" {
|
|
601
|
+
return &usageError{fmt.Errorf("--delete-session 需要一个 session_id 或 run_id(--sessions 可列出全部)")}
|
|
602
|
+
}
|
|
603
|
+
res, err := session.Delete(id)
|
|
604
|
+
if err != nil {
|
|
605
|
+
if errors.Is(err, os.ErrInvalid) {
|
|
606
|
+
return &usageError{fmt.Errorf("--delete-session %q:id 不能为空,也不能含路径分隔符 / 控制字符", id)}
|
|
607
|
+
}
|
|
608
|
+
return fmt.Errorf("--delete-session %s: %w", id, err)
|
|
609
|
+
}
|
|
610
|
+
if opts.output == "text" {
|
|
611
|
+
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
|
612
|
+
"magic-agent: 已删除会话 %s(登记记录 %d 条 / 历史 %d 份 / 标题 %d 条)\n",
|
|
613
|
+
res.ID, res.RecordsRemoved, res.LogsRemoved, res.TitlesRemoved)
|
|
614
|
+
return nil
|
|
615
|
+
}
|
|
616
|
+
out := struct {
|
|
617
|
+
Type string `json:"type"`
|
|
618
|
+
ID string `json:"id"`
|
|
619
|
+
RecordsRemoved int `json:"records_removed"`
|
|
620
|
+
LogsRemoved int `json:"logs_removed"`
|
|
621
|
+
TitlesRemoved int `json:"titles_removed"`
|
|
622
|
+
}{
|
|
623
|
+
Type: "delete",
|
|
624
|
+
ID: res.ID,
|
|
625
|
+
RecordsRemoved: res.RecordsRemoved,
|
|
626
|
+
LogsRemoved: res.LogsRemoved,
|
|
627
|
+
TitlesRemoved: res.TitlesRemoved,
|
|
628
|
+
}
|
|
629
|
+
return writeJSONLine(cmd.OutOrStdout(), out)
|
|
630
|
+
}
|
|
631
|
+
|
|
478
632
|
// writeJSONLine 写一行 JSON(复用 printJSON 的编码方式,单独留个名便于阅读)。
|
|
479
633
|
func writeJSONLine(w io.Writer, v any) error {
|
|
480
634
|
data, err := json.Marshal(v)
|
|
@@ -19,12 +19,110 @@ import (
|
|
|
19
19
|
"github.com/darren/magic-agent/internal/session"
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// TestSummarizeTitleCachedAcrossKeys 追问轮按 session_id 归纳时,应命中首轮存在 run_id 上的
|
|
23
|
+
// 标题(不调 LLM),并把它迁到 session_id 上 —— 否则每轮追问都会重算一次标题。
|
|
24
|
+
//
|
|
25
|
+
// 用户 2026-10-15 报的「标题每次追问也在更新」就是这条链路:键落在会变的 run_id 上 +
|
|
26
|
+
// 归纳输入回落成**当前这轮**的 prompt_head。
|
|
27
|
+
func TestSummarizeTitleCachedAcrossKeys(t *testing.T) {
|
|
28
|
+
t.Setenv(session.EnvDir, t.TempDir())
|
|
29
|
+
oldLog := SessionLogDirOverride
|
|
30
|
+
defer func() { SessionLogDirOverride = oldLog }()
|
|
31
|
+
SessionLogDirOverride = t.TempDir()
|
|
32
|
+
registerFake(&userEchoStreamEngine{name: "fake-title-a", sessionID: "sess-t1"})
|
|
33
|
+
|
|
34
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "--save-history",
|
|
35
|
+
"-e", "fake-title-a", "--stream", "第一句需求"); err != nil {
|
|
36
|
+
t.Fatalf("ask: %v", err)
|
|
37
|
+
}
|
|
38
|
+
rows := parseSessions(t, mustSessions(t))
|
|
39
|
+
if len(rows) != 1 || rows[0].SessionID != "sess-t1" {
|
|
40
|
+
t.Fatalf("前置条件不成立(应有 1 条带 session_id 的记录):%+v", rows)
|
|
41
|
+
}
|
|
42
|
+
// 模拟首轮归纳的结果:键留在了那一轮的 run_id 上。
|
|
43
|
+
if err := session.SetTitle(rows[0].RunID, "初次归纳的标题"); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
out, _, err := runAskCmd(t, "", "--summarize-title", "sess-t1")
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatalf("跨键应命中缓存、不该报错:%v", err)
|
|
50
|
+
}
|
|
51
|
+
if !strings.Contains(out, "初次归纳的标题") || !strings.Contains(out, `"cached":true`) {
|
|
52
|
+
t.Fatalf("应回现有标题且 cached=true,实际 %s", out)
|
|
53
|
+
}
|
|
54
|
+
if session.TitleFor("sess-t1") == "" {
|
|
55
|
+
t.Fatal("命中时应把标题迁到 session_id 上(否则下一轮又要重算)")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TestSummarizeTitleCachedFromOlderRun 标题挂在**上一轮**的 run_id 上(首轮归纳时还没有
|
|
60
|
+
// session_id)→ 追问轮按 session_id 归纳必须命中它、**不重写**标题。
|
|
61
|
+
func TestSummarizeTitleCachedFromOlderRun(t *testing.T) {
|
|
62
|
+
t.Setenv(session.EnvDir, t.TempDir())
|
|
63
|
+
oldLog := SessionLogDirOverride
|
|
64
|
+
defer func() { SessionLogDirOverride = oldLog }()
|
|
65
|
+
SessionLogDirOverride = t.TempDir()
|
|
66
|
+
registerFake(&userEchoStreamEngine{name: "fake-title-c", sessionID: "sess-t3"})
|
|
67
|
+
|
|
68
|
+
// 同一会话跑两轮(同一个 session_id、两个 run_id)。
|
|
69
|
+
for _, prompt := range []string{"第一句", "追问一句"} {
|
|
70
|
+
if _, _, err := runAskCmd(t, "", "--save-registry",
|
|
71
|
+
"-e", "fake-title-c", "--stream", prompt); err != nil {
|
|
72
|
+
t.Fatalf("ask: %v", err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
rows := parseSessions(t, mustSessions(t))
|
|
76
|
+
if len(rows) != 2 {
|
|
77
|
+
t.Fatalf("应有两轮记录,实得 %d", len(rows))
|
|
78
|
+
}
|
|
79
|
+
// 把标题挂在**较早**那一轮上(rows 是新→旧)。
|
|
80
|
+
if err := session.SetTitle(rows[1].RunID, "第一次生成的标题"); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
out, _, err := runAskCmd(t, "", "--summarize-title", "sess-t3")
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatalf("应命中已有标题、不该报错:%v", err)
|
|
87
|
+
}
|
|
88
|
+
if !strings.Contains(out, "第一次生成的标题") || !strings.Contains(out, `"cached":true`) {
|
|
89
|
+
t.Fatalf("应回第一次生成的标题且 cached=true,实际 %s", out)
|
|
90
|
+
}
|
|
91
|
+
if got := session.TitleFor("sess-t3"); got != "第一次生成的标题" {
|
|
92
|
+
t.Fatalf("应把标题归一到 session_id 上,实得 %q", got)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// TestSummarizeTitleNeedsFirstPrompt 有 session_id 但没有事件日志时**拒绝**归纳:
|
|
97
|
+
// prompt_head 记的是**当前这轮**的提示词,追问轮拿它当首问,标题就会跟着最新追问变。
|
|
98
|
+
func TestSummarizeTitleNeedsFirstPrompt(t *testing.T) {
|
|
99
|
+
t.Setenv(session.EnvDir, t.TempDir())
|
|
100
|
+
oldLog := SessionLogDirOverride
|
|
101
|
+
defer func() { SessionLogDirOverride = oldLog }()
|
|
102
|
+
SessionLogDirOverride = t.TempDir()
|
|
103
|
+
registerFake(&userEchoStreamEngine{name: "fake-title-b", sessionID: "sess-t2"})
|
|
104
|
+
|
|
105
|
+
// 故意不传 --save-history:这条会话没有事件日志。
|
|
106
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-title-b", "--stream", "继续"); err != nil {
|
|
107
|
+
t.Fatalf("ask: %v", err)
|
|
108
|
+
}
|
|
109
|
+
_, _, err := runAskCmd(t, "", "--summarize-title", "sess-t2")
|
|
110
|
+
if err == nil {
|
|
111
|
+
t.Fatal("读不到首问时应拒绝归纳,而不是拿 prompt_head 凑一个「最新追问」的标题")
|
|
112
|
+
}
|
|
113
|
+
if got := session.TitleFor("sess-t2"); got != "" {
|
|
114
|
+
t.Fatalf("拒绝时不该写入任何标题,实得 %q", got)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// sessionRow 解析 --sessions 的 JSON 输出。
|
|
23
119
|
type sessionRow struct {
|
|
24
120
|
RunID string `json:"run_id"`
|
|
25
121
|
SessionID string `json:"session_id"`
|
|
26
122
|
PID int `json:"pid"`
|
|
27
123
|
Engine string `json:"engine"`
|
|
124
|
+
Source string `json:"source"`
|
|
125
|
+
Client string `json:"client"`
|
|
28
126
|
State string `json:"state"`
|
|
29
127
|
Alive bool `json:"alive"`
|
|
30
128
|
}
|
|
@@ -44,7 +142,7 @@ func TestAskRegistersSession(t *testing.T) {
|
|
|
44
142
|
t.Setenv(session.EnvDir, dir)
|
|
45
143
|
registerFake(&stringEngine{name: "fake-sess-reg", text: "ok"})
|
|
46
144
|
|
|
47
|
-
if _, _, err := runAskCmd(t, "", "-e", "fake-sess-reg", "你好"); err != nil {
|
|
145
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-reg", "你好"); err != nil {
|
|
48
146
|
t.Fatalf("ask: %v", err)
|
|
49
147
|
}
|
|
50
148
|
|
|
@@ -72,7 +170,7 @@ func TestStreamAskRegistersSession(t *testing.T) {
|
|
|
72
170
|
t.Setenv(session.EnvDir, dir)
|
|
73
171
|
registerFake(&streamingEngine{name: "fake-sess-stream", text: "ok"})
|
|
74
172
|
|
|
75
|
-
if _, _, err := runAskCmd(t, "", "-e", "fake-sess-stream", "--stream", "hi"); err != nil {
|
|
173
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-stream", "--stream", "hi"); err != nil {
|
|
76
174
|
t.Fatalf("stream ask: %v", err)
|
|
77
175
|
}
|
|
78
176
|
rows := parseSessions(t, mustSessions(t))
|
|
@@ -91,7 +189,7 @@ func TestAskBackfillsSessionID(t *testing.T) {
|
|
|
91
189
|
t.Setenv(session.EnvDir, dir)
|
|
92
190
|
registerFake(&sidEngine{name: "fake-sess-sid", text: "ok", sid: "sid-from-engine"})
|
|
93
191
|
|
|
94
|
-
if _, _, err := runAskCmd(t, "", "-e", "fake-sess-sid", "hi"); err != nil {
|
|
192
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-sid", "hi"); err != nil {
|
|
95
193
|
t.Fatalf("ask: %v", err)
|
|
96
194
|
}
|
|
97
195
|
rows := parseSessions(t, mustSessions(t))
|
|
@@ -107,6 +205,47 @@ func TestAskBackfillsSessionID(t *testing.T) {
|
|
|
107
205
|
}
|
|
108
206
|
}
|
|
109
207
|
|
|
208
|
+
// 来源端与来源客户端(2026-10-12)由发起方经环境变量带进来,必须**如实落进记录**:
|
|
209
|
+
// 客户端(magic-client)靠 client 这一列把自己的会话与「直接跑 CLI 的」区分开,
|
|
210
|
+
// 靠 source 那一列显示手机 / 电脑标志。两个都缺,列表就会混进别人的任务。
|
|
211
|
+
func TestAskRecordsSourceAndClient(t *testing.T) {
|
|
212
|
+
dir := t.TempDir()
|
|
213
|
+
t.Setenv(session.EnvDir, dir)
|
|
214
|
+
t.Setenv(session.EnvSource, "mobile")
|
|
215
|
+
t.Setenv(session.EnvClient, "magic-client")
|
|
216
|
+
registerFake(&stringEngine{name: "fake-sess-src", text: "ok"})
|
|
217
|
+
|
|
218
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-src", "hi"); err != nil {
|
|
219
|
+
t.Fatalf("ask: %v", err)
|
|
220
|
+
}
|
|
221
|
+
rows := parseSessions(t, mustSessions(t))
|
|
222
|
+
if len(rows) != 1 {
|
|
223
|
+
t.Fatalf("登记表条数 = %d want 1: %+v", len(rows), rows)
|
|
224
|
+
}
|
|
225
|
+
if rows[0].Source != session.SourceMobile || rows[0].Client != "magic-client" {
|
|
226
|
+
t.Errorf("source/client 没落进记录: %+v", rows[0])
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 直接跑 CLI(没人注入这两个环境变量)时,两列都该是空 —— 客户端据此把它们排除在外。
|
|
231
|
+
func TestAskWithoutClientEnvLeavesSourceEmpty(t *testing.T) {
|
|
232
|
+
t.Setenv(session.EnvDir, t.TempDir())
|
|
233
|
+
t.Setenv(session.EnvSource, "")
|
|
234
|
+
t.Setenv(session.EnvClient, "")
|
|
235
|
+
registerFake(&stringEngine{name: "fake-sess-nosrc", text: "ok"})
|
|
236
|
+
|
|
237
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-nosrc", "hi"); err != nil {
|
|
238
|
+
t.Fatalf("ask: %v", err)
|
|
239
|
+
}
|
|
240
|
+
rows := parseSessions(t, mustSessions(t))
|
|
241
|
+
if len(rows) != 1 {
|
|
242
|
+
t.Fatalf("登记表条数 = %d want 1: %+v", len(rows), rows)
|
|
243
|
+
}
|
|
244
|
+
if rows[0].Source != "" || rows[0].Client != "" {
|
|
245
|
+
t.Errorf("没人注入来源时不该有值: %+v", rows[0])
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
110
249
|
// sidEngine 固定返回一个 session_id 的假引擎(验回填)。
|
|
111
250
|
type sidEngine struct {
|
|
112
251
|
name string
|
|
@@ -153,7 +292,7 @@ func TestStopFinishedSessionIsIdempotent(t *testing.T) {
|
|
|
153
292
|
t.Setenv(session.EnvDir, dir)
|
|
154
293
|
registerFake(&stringEngine{name: "fake-sess-fin", text: "ok"})
|
|
155
294
|
|
|
156
|
-
if _, _, err := runAskCmd(t, "", "-e", "fake-sess-fin", "hi"); err != nil {
|
|
295
|
+
if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-sess-fin", "hi"); err != nil {
|
|
157
296
|
t.Fatalf("ask: %v", err)
|
|
158
297
|
}
|
|
159
298
|
runID := parseSessions(t, mustSessions(t))[0].RunID
|