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
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"
|
|
@@ -106,17 +106,27 @@ type keepAlive struct {
|
|
|
106
106
|
closed bool // 收工后为 true:Push 据此拒收(与 close(in) 同锁,避免向已关闭通道发送)
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// kaEnabled 常驻会话是否生效:只在「流式 +
|
|
110
|
-
//
|
|
111
|
-
// 里(那里能拿到 cmd 与引擎)。
|
|
109
|
+
// kaEnabled 常驻会话是否生效:只在「流式 + 引擎支持追加 + 本平台有追加入口」时才有意义
|
|
110
|
+
// —— 不满足就静默不启用(不影响原有调用形态)。显式传 `--keep-alive` 却不满足条件的
|
|
111
|
+
// 报错在 prepareAsk 里(那里能拿到 cmd 与引擎)。
|
|
112
112
|
//
|
|
113
113
|
// 默认值语义**按引擎区分**(agent.AppendDefaultOn):claude/codebuddy 默认常驻
|
|
114
114
|
// (用户 2026-09-18 的要求);dsh 默认关、要显式 `--keep-alive` —— 它的 SDK 通道本来
|
|
115
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 报错(用户明确要了,要说清为什么不行)。
|
|
116
123
|
func kaEnabled(cmd *cobra.Command, opts *askOptions, engine string) bool {
|
|
117
124
|
if !opts.stream || !agent.AppendSupportOf(engine) {
|
|
118
125
|
return false
|
|
119
126
|
}
|
|
127
|
+
if !session.AppendTransportAvailable() {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
120
130
|
if flagChanged(cmd, "keep-alive") {
|
|
121
131
|
return opts.keepAlive // 显式开关优先于引擎默认值
|
|
122
132
|
}
|
|
@@ -324,12 +334,28 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
|
|
|
324
334
|
PID int `json:"pid"`
|
|
325
335
|
Engine string `json:"engine"`
|
|
326
336
|
Model string `json:"model,omitempty"`
|
|
337
|
+
Source string `json:"source,omitempty"`
|
|
338
|
+
Client string `json:"client,omitempty"`
|
|
327
339
|
State string `json:"state"`
|
|
328
340
|
Alive bool `json:"alive"`
|
|
329
341
|
Workspace string `json:"workspace,omitempty"`
|
|
330
342
|
PromptHead string `json:"prompt_head,omitempty"`
|
|
331
|
-
|
|
332
|
-
|
|
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"`
|
|
333
359
|
// HasLog 这条会话有没有**可回放的事件日志**(`--session-log` 读的就是它)。
|
|
334
360
|
// 为什么让 CLI 来说这件事:日志**只在流式调用时写**(见 sessionlog.go 的 sessionWriter),
|
|
335
361
|
// 所以非流式跑出来的会话没有历史可回放 —— 客户端要拿它判断「这条点开有东西看吗」,
|
|
@@ -339,32 +365,43 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
|
|
|
339
365
|
logDir, _ := sessionLogDir()
|
|
340
366
|
rows := make([]row, 0, len(records))
|
|
341
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
|
+
}
|
|
342
376
|
rows = append(rows, row{
|
|
343
|
-
RunID:
|
|
344
|
-
SessionID:
|
|
345
|
-
PID:
|
|
346
|
-
Engine:
|
|
347
|
-
Model:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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),
|
|
355
393
|
})
|
|
356
394
|
}
|
|
357
395
|
return printJSON(cmd.OutOrStdout(), rows)
|
|
358
396
|
}
|
|
359
397
|
|
|
360
|
-
// hasSessionLog
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
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)
|
|
368
405
|
}
|
|
369
406
|
|
|
370
407
|
// runSessionLog 读一段会话的**事件历史**:`--session-log <id> [--after <seq>]`。
|
|
@@ -386,15 +423,76 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
|
|
|
386
423
|
if err != nil {
|
|
387
424
|
return fmt.Errorf("--session-log %s: 找不到历史目录: %w", id, err)
|
|
388
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
|
+
}
|
|
389
477
|
|
|
390
478
|
if flagChanged(cmd, "after") {
|
|
391
|
-
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
|
+
}
|
|
392
487
|
if err != nil {
|
|
393
488
|
return fmt.Errorf("--session-log %s: %w", id, err)
|
|
394
489
|
}
|
|
395
490
|
if evs == nil {
|
|
396
491
|
evs = []sessionEvent{}
|
|
397
492
|
}
|
|
493
|
+
if opts.logLean {
|
|
494
|
+
stripSnapshots(evs) // 双保险:lean 读已摘过,这里只兜住未来的新调用方
|
|
495
|
+
}
|
|
398
496
|
return printJSON(cmd.OutOrStdout(), struct {
|
|
399
497
|
Session string `json:"session"`
|
|
400
498
|
Events []sessionEvent `json:"events"`
|
|
@@ -408,13 +506,21 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
|
|
|
408
506
|
LastSeq: maxSeq, SnapshotRequired: opts.logAfter > maxSeq})
|
|
409
507
|
}
|
|
410
508
|
|
|
411
|
-
evs
|
|
509
|
+
var evs []sessionEvent
|
|
510
|
+
if opts.logLean {
|
|
511
|
+
evs, err = ReadSessionLogLean(dir, id)
|
|
512
|
+
} else {
|
|
513
|
+
evs, err = ReadSessionLog(dir, id)
|
|
514
|
+
}
|
|
412
515
|
if err != nil {
|
|
413
516
|
return fmt.Errorf("--session-log %s: %w", id, err)
|
|
414
517
|
}
|
|
415
518
|
if evs == nil {
|
|
416
519
|
evs = []sessionEvent{}
|
|
417
520
|
}
|
|
521
|
+
if opts.logLean {
|
|
522
|
+
stripSnapshots(evs) // 双保险,同上面 --after 分支
|
|
523
|
+
}
|
|
418
524
|
return printJSON(cmd.OutOrStdout(), struct {
|
|
419
525
|
Session string `json:"session"`
|
|
420
526
|
Events []sessionEvent `json:"events"`
|
|
@@ -482,6 +588,47 @@ func stopText(res session.StopResult) string {
|
|
|
482
588
|
return fmt.Sprintf("magic-agent: 会话 %s 未在运行:%s(state=%s)", who, reason, res.Record.State)
|
|
483
589
|
}
|
|
484
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
|
+
|
|
485
632
|
// writeJSONLine 写一行 JSON(复用 printJSON 的编码方式,单独留个名便于阅读)。
|
|
486
633
|
func writeJSONLine(w io.Writer, v any) error {
|
|
487
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
|