feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,71 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
)
|
||||
|
||||
// Checkpoint 是一次上下文压缩留下的交接文档,以及它覆盖到原 transcript 的哪个位置。
|
||||
//
|
||||
// 它值得落盘:压缩本身只改视图,但 WebUI 每个回合都会新建 Agent。没有 sidecar,
|
||||
// 新回合又要把整段历史重新摘要一遍;有了它,新回合直接从 checkpoint + 未覆盖的
|
||||
// 新消息开始。Covered/TailHash 是失效保护——历史被回退或改写后,指纹对不上就
|
||||
// 当作没有 checkpoint,绝不拿一份覆盖了不同消息的旧摘要继续跑。
|
||||
type Checkpoint struct {
|
||||
Text string `json:"text"`
|
||||
Covered int `json:"covered"` // 已纳入 checkpoint 的消息条数(transcript 前缀)
|
||||
TailHash string `json:"tail_hash"` // 第 Covered 条消息的指纹
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CheckpointPath 是压缩交接文档的 sidecar 路径(与 .title / .run.json 同目录)。
|
||||
func CheckpointPath(id string) string {
|
||||
return filepath.Join(Dir(), id+".checkpoint.json")
|
||||
}
|
||||
|
||||
// LoadCheckpoint 读取 checkpoint;缺失或损坏返回 false。
|
||||
func LoadCheckpoint(id string) (Checkpoint, bool) {
|
||||
if _, ok := SafeID(id); !ok {
|
||||
return Checkpoint{}, false
|
||||
}
|
||||
var cp Checkpoint
|
||||
if err := LoadState(CheckpointPath(id), &cp); err != nil {
|
||||
return Checkpoint{}, false
|
||||
}
|
||||
return cp, cp.Text != ""
|
||||
}
|
||||
|
||||
// SaveCheckpoint 原子写 checkpoint。
|
||||
func SaveCheckpoint(id string, cp Checkpoint) error {
|
||||
if _, ok := SafeID(id); !ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
if cp.UpdatedAt == 0 {
|
||||
cp.UpdatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
return SaveState(CheckpointPath(id), cp)
|
||||
}
|
||||
|
||||
// RemoveCheckpoint 删除 checkpoint sidecar(会话删除时用)。
|
||||
func RemoveCheckpoint(id string) {
|
||||
if _, ok := SafeID(id); ok {
|
||||
_ = os.Remove(CheckpointPath(id))
|
||||
}
|
||||
}
|
||||
|
||||
// MessageFingerprint 是一条消息的稳定指纹,用于识别 checkpoint 的覆盖边界。
|
||||
// 只取会进模型视野的字段(role/content/tool call),不含本地 trace。
|
||||
func MessageFingerprint(m llm.Message) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%s\x00", m.Role, m.Content, m.ToolCallID)
|
||||
for _, tc := range m.ToolCalls {
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%s\x00", tc.ID, tc.Name, tc.Input)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 任务会话标记:定时任务(scheduler)每次触发创建的会话在此留痕。
|
||||
//
|
||||
// 用途有二:
|
||||
// 1. 出处可查——standalone 任务每次一个新会话,时间戳命名与手工临时对话
|
||||
// 无法区分,没有标记就既不能在 UI 归组、也不敢做自动清理;
|
||||
// 2. 会话清扫的依据——只有带标记的会话才按保留期回收(手工会话、
|
||||
// existing 模式的复用线程、项目绑定会话一律不动)。
|
||||
//
|
||||
// 与 wsbind 同一套 sidecar 命名约定:<会话ID>.job.json。
|
||||
|
||||
// JobMarker 是任务会话的出处标记。
|
||||
type JobMarker struct {
|
||||
JobID string `json:"job_id"`
|
||||
JobName string `json:"job_name"`
|
||||
At time.Time `json:"at"` // 会话创建时刻(清扫按此判保留期)
|
||||
}
|
||||
|
||||
// JobMarkerPath 标记 sidecar 路径。
|
||||
func JobMarkerPath(id string) string {
|
||||
return filepath.Join(Dir(), id+".job.json")
|
||||
}
|
||||
|
||||
// SaveJobMarker 写入任务会话标记(best-effort 调用方不判错也行)。
|
||||
func SaveJobMarker(id, jobID, jobName string) error {
|
||||
return SaveJobMarkerAt(id, jobID, jobName, time.Now())
|
||||
}
|
||||
|
||||
// SaveJobMarkerAt 同 SaveJobMarker,时间由调用方指定(测试注入用)。
|
||||
func SaveJobMarkerAt(id, jobID, jobName string, at time.Time) error {
|
||||
if id == "" || jobID == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(JobMarker{JobID: jobID, JobName: jobName, At: at})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(Dir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(JobMarkerPath(id), b, 0o644)
|
||||
}
|
||||
|
||||
// LoadJobMarker 读取任务会话标记;未标记返回 false。
|
||||
func LoadJobMarker(id string) (JobMarker, bool) {
|
||||
b, err := os.ReadFile(JobMarkerPath(id))
|
||||
if err != nil {
|
||||
return JobMarker{}, false
|
||||
}
|
||||
var m JobMarker
|
||||
if json.Unmarshal(b, &m) != nil {
|
||||
return JobMarker{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// RemoveJobMarker 删除标记(随会话清扫一起走)。
|
||||
func RemoveJobMarker(id string) error {
|
||||
err := os.Remove(JobMarkerPath(id))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestJobMarkerRoundtrip:任务会话标记的写入与读取。
|
||||
func TestJobMarkerRoundtrip(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
at := time.Date(2026, 9, 30, 9, 0, 0, 0, time.Local)
|
||||
if err := SaveJobMarkerAt("sess-1", "cron-喝水提醒", "喝水提醒", at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := LoadJobMarker("sess-1")
|
||||
if !ok {
|
||||
t.Fatal("标记写入后读不到")
|
||||
}
|
||||
if m.JobID != "cron-喝水提醒" || m.JobName != "喝水提醒" || !m.At.Equal(at) {
|
||||
t.Fatalf("标记内容不符: %+v", m)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(Dir(), "sess-1.job.json")); err != nil {
|
||||
t.Fatalf("sidecar 文件不存在: %v", err)
|
||||
}
|
||||
|
||||
// 未标记的会话读不到;Remove 后同样读不到。
|
||||
if _, ok := LoadJobMarker("sess-other"); ok {
|
||||
t.Fatal("未标记会话不该有标记")
|
||||
}
|
||||
if err := RemoveJobMarker("sess-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := LoadJobMarker("sess-1"); ok {
|
||||
t.Fatal("Remove 后仍能读到标记")
|
||||
}
|
||||
// Remove 幂等(不存在不报错)。
|
||||
if err := RemoveJobMarker("sess-1"); err != nil {
|
||||
t.Fatalf("重复 Remove 报错: %v", err)
|
||||
}
|
||||
// 空参数不写文件。
|
||||
if err := SaveJobMarker("", "j", "n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveJobMarker("sess-2", "", "n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := LoadJobMarker("sess-2"); ok {
|
||||
t.Fatal("空 jobID 不该写出标记")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
)
|
||||
|
||||
// TestNewNameSkipsExisting:时间戳撞上已有文件时依次加 -2/-3,绝不指向别人的会话。
|
||||
// 不依赖时钟(直接喂固定 ts),所以不会因跨过一秒而变哑。
|
||||
func TestNewNameSkipsExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := "20260101-000000"
|
||||
|
||||
if got := newName(dir, ts); got != filepath.Join(dir, ts+".jsonl") {
|
||||
t.Fatalf("干净目录里应直接用基础名,得到 %q", got)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(dir, ts+".jsonl"))
|
||||
if got := newName(dir, ts); got != filepath.Join(dir, ts+"-2.jsonl") {
|
||||
t.Fatalf("基础名被占时应给 -2,得到 %q", got)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(dir, ts+"-2.jsonl"))
|
||||
if got := newName(dir, ts); got != filepath.Join(dir, ts+"-3.jsonl") {
|
||||
t.Fatalf("前两个都被占时应给 -3,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDoesNotRecoverOldSession:连续两次新建必须拿到不同名字,第二份还是空的。
|
||||
// 撞名的真实后果:/session new 后立刻发消息(文件写下内容),同一秒内再新建,
|
||||
// 前端拿 LoadOrCreate 打开同名文件,"新建"就变成了把旧会话整份恢复回来。
|
||||
func TestNewDoesNotRecoverOldSession(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
first, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := first.Append(llm.Message{Role: llm.RoleUser, Content: "history"}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
second, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if second.Path == first.Path {
|
||||
t.Fatalf("两次新建拿到同一个名字 %s", second.Path)
|
||||
}
|
||||
if len(second.Messages) != 0 {
|
||||
t.Fatalf("新会话应为空,得到 %d 条", len(second.Messages))
|
||||
}
|
||||
if _, err := os.Stat(second.Path); !os.IsNotExist(err) {
|
||||
t.Fatalf("新会话文件不该在这一步出现(err=%v)", err)
|
||||
}
|
||||
// 经渠道/前端实际会走的路径再打开一次,仍然是空的
|
||||
byName := strings.TrimSuffix(filepath.Base(second.Path), ".jsonl")
|
||||
again, err := LoadOrCreate(byName)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if len(again.Messages) != 0 {
|
||||
t.Fatalf("按名字打开新会话却拿到 %d 条消息", len(again.Messages))
|
||||
}
|
||||
|
||||
// 第一份的历史不受影响
|
||||
reloaded, err := Load(first.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(reloaded.Messages) != 1 || reloaded.Messages[0].Content != "history" {
|
||||
t.Fatalf("旧会话被改动: %+v", reloaded.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewNameSkipsUnwrittenIssued:两份新会话都还没落盘时也不能同名。
|
||||
// 会话文件要到第一条消息才写出来,所以"盘上没有"不等于"没人用"。
|
||||
func TestNewNameSkipsUnwrittenIssued(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := "20260202-120000"
|
||||
|
||||
first := newName(dir, ts)
|
||||
second := newName(dir, ts)
|
||||
if first == second {
|
||||
t.Fatalf("两次取名拿到同一个名字 %s(第一份还没落盘,Stat 看不见)", first)
|
||||
}
|
||||
if strings.TrimSuffix(filepath.Base(second), ".jsonl") != ts+"-2" {
|
||||
t.Fatalf("第二个名字 = %q,want %s-2.jsonl", second, ts)
|
||||
}
|
||||
// 已发出的名字不许被复用
|
||||
if got := newName(dir, ts); got == first || got == second {
|
||||
t.Fatalf("复用了已发出的名字: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RunState 是一次回合的**落盘**状态,供 UI 在进程重启后说明"上次那次跑到哪了"。
|
||||
//
|
||||
// 注意它能表达的东西有边界:agent 在进程内,进程重启无法续跑。所以这个文件的
|
||||
// 用途只有一个——把上次遗留的 running 认出来是 interrupted,而不是假装它还在跑。
|
||||
// 会话消息本身是逐条落盘的(见 Session.Append),不会因为重启丢失。
|
||||
type RunState struct {
|
||||
RunID string `json:"run_id"`
|
||||
Status string `json:"status"` // running | done | error | stopped | interrupted
|
||||
StartedAt int64 `json:"started_at"`
|
||||
EndedAt int64 `json:"ended_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RunStatePath 运行状态 sidecar 路径(与 TitlePath 同一套命名与目录)。
|
||||
func RunStatePath(id string) string {
|
||||
return filepath.Join(Dir(), id+".run.json")
|
||||
}
|
||||
|
||||
// LoadRunState 读取运行状态;文件不存在或损坏时返回 (zero, false)。
|
||||
func LoadRunState(id string) (RunState, bool) {
|
||||
b, err := os.ReadFile(RunStatePath(id))
|
||||
if err != nil {
|
||||
return RunState{}, false
|
||||
}
|
||||
var st RunState
|
||||
if json.Unmarshal(b, &st) != nil {
|
||||
return RunState{}, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
// SaveRunState 原子写运行状态。
|
||||
func SaveRunState(id string, st RunState) error {
|
||||
return SaveState(RunStatePath(id), st)
|
||||
}
|
||||
|
||||
// ReconcileRunStates 把所有遗留的 running 状态改写成 interrupted,返回改写条数。
|
||||
//
|
||||
// 进程刚起来时调用:此时不可能有任何回合在跑,文件里写着 running 的一定是上次
|
||||
// 进程退出/被杀时留下的。不这么做的话,UI 会永远显示"正在回复…"。
|
||||
func ReconcileRunStates() int {
|
||||
entries, err := os.ReadDir(Dir())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".run.json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(Dir(), name)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var st RunState
|
||||
if json.Unmarshal(b, &st) != nil || st.Status != "running" {
|
||||
continue
|
||||
}
|
||||
st.Status = "interrupted"
|
||||
if err := SaveState(path, st); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunStateRoundTrip:写读往返。
|
||||
func TestRunStateRoundTrip(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
want := RunState{RunID: "run-1", Status: "running", StartedAt: 1234}
|
||||
if err := SaveRunState("sess1", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := LoadRunState("sess1")
|
||||
if !ok {
|
||||
t.Fatal("读不回刚写的状态")
|
||||
}
|
||||
if got.RunID != want.RunID || got.Status != want.Status || got.StartedAt != want.StartedAt {
|
||||
t.Errorf("往返不一致: %+v vs %+v", got, want)
|
||||
}
|
||||
if _, ok := LoadRunState("nope"); ok {
|
||||
t.Error("不存在的会话不该返回状态")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileRunStates:进程刚起来时把遗留的 running 认成 interrupted,
|
||||
// 其它状态原样保留(不能把上次正常完成的 done 也改掉)。
|
||||
func TestReconcileRunStates(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if err := os.MkdirAll(Dir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []RunState{
|
||||
{RunID: "a", Status: "running", StartedAt: 1},
|
||||
{RunID: "b", Status: "done", StartedAt: 1, EndedAt: 2},
|
||||
{RunID: "c", Status: "interrupted", StartedAt: 1},
|
||||
}
|
||||
for i, st := range cases {
|
||||
if err := SaveRunState(string(rune('a'+i)), st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 损坏的文件不该让 reconcile 崩掉
|
||||
if err := os.WriteFile(filepath.Join(Dir(), "broken.run.json"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 非 run.json 的文件不该被动
|
||||
if err := os.WriteFile(filepath.Join(Dir(), "keep.jsonl"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := ReconcileRunStates(); n != 1 {
|
||||
t.Errorf("改写条数 = %d, want 1(只有 running 那条)", n)
|
||||
}
|
||||
if st, _ := LoadRunState("a"); st.Status != "interrupted" {
|
||||
t.Errorf("running 应被改成 interrupted,实际 %q", st.Status)
|
||||
}
|
||||
if st, _ := LoadRunState("b"); st.Status != "done" {
|
||||
t.Errorf("done 不该被改,实际 %q", st.Status)
|
||||
}
|
||||
if st, _ := LoadRunState("c"); st.Status != "interrupted" {
|
||||
t.Errorf("已经是 interrupted 的不该变,实际 %q", st.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(Dir(), "keep.jsonl")); err != nil {
|
||||
t.Errorf("非 run.json 文件被动过了: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// Package session 实现 JSONL 会话持久化(借鉴 picoclaw 的 jsonl_backend):
|
||||
// 每条消息一行,追加写;resume 时整体重放。状态文件用临时文件 + rename 原子写。
|
||||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/jsonl"
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
)
|
||||
|
||||
// dirOverride 非空时 Dir() 返回它,供宿主自定义会话存储布局;
|
||||
// 默认空,走 ~/.autogo/sessions(与来源仓库 AutoGo 保持镜像)。
|
||||
var dirOverride string
|
||||
|
||||
// SetDir 覆盖会话存储目录。须在进程启动时调用一次;并发调用不设防。
|
||||
func SetDir(d string) { dirOverride = d }
|
||||
|
||||
// Dir 返回会话存储目录:~/.autogo/sessions。
|
||||
func Dir() string {
|
||||
if dirOverride != "" {
|
||||
return dirOverride
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".autogo-sessions"
|
||||
}
|
||||
return filepath.Join(home, ".autogo", "sessions")
|
||||
}
|
||||
|
||||
// Session 是一个可持久化的会话。
|
||||
type Session struct {
|
||||
Path string
|
||||
Messages []llm.Message
|
||||
// Retired 是被游标退役、仍原样留在文件里的消息条数。它们不进上下文,但**没被删**
|
||||
// ——这正是回退敢做的原因:活跃视图可以缩,文件不缩。
|
||||
Retired int
|
||||
// retired 是那些消息本身。留着它,Rewrite 才能把历史原样写回去:
|
||||
// collectToolImages 那类"更新已有消息字段"的调用会整体重写文件,如果不保留
|
||||
// 退役内容,一次图片缓存就把刚做的回退历史抹掉了。
|
||||
retired []llm.Message
|
||||
// TurnEnds 是文件里的回合结束记录(按发生顺序)。回合"为什么停"必须能在会话
|
||||
// 文件本身看到:只导出 JSONL 取证时,光看最后一条消息分不清是模型说完、
|
||||
// 用户停止、报错,还是被步数上限切断。
|
||||
TurnEnds []TurnEnd
|
||||
}
|
||||
|
||||
// turnEndKind 是回合结束记录行的判别值(与消息不撞:消息一定有 role)。
|
||||
const turnEndKind = "autogo.turn_end"
|
||||
|
||||
// TurnEnd 是一次回合的退出原因。
|
||||
type TurnEnd struct {
|
||||
Reason string `json:"reason"` // completed | stopped | failed | max_turns
|
||||
Error string `json:"error,omitempty"`
|
||||
Step int `json:"step,omitempty"` // 本回合跑到第几步时结束
|
||||
Ms int64 `json:"ms,omitempty"`
|
||||
At int64 `json:"at,omitempty"` // unix ms
|
||||
}
|
||||
|
||||
type turnEndRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Step int `json:"step,omitempty"`
|
||||
Ms int64 `json:"ms,omitempty"`
|
||||
At int64 `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// parseTurnEnd 判断这一行是不是一条回合结束记录。同样先用字节查找筛掉消息行。
|
||||
func parseTurnEnd(line []byte) (TurnEnd, bool) {
|
||||
if !bytes.Contains(line, []byte(`"`+turnEndKind+`"`)) {
|
||||
return TurnEnd{}, false
|
||||
}
|
||||
var r turnEndRecord
|
||||
if json.Unmarshal(line, &r) != nil || r.Kind != turnEndKind {
|
||||
return TurnEnd{}, false
|
||||
}
|
||||
return TurnEnd{Reason: r.Reason, Error: r.Error, Step: r.Step, Ms: r.Ms, At: r.At}, true
|
||||
}
|
||||
|
||||
// AppendTurnEnd 追加一条回合结束记录。best-effort:这是账,不该拦住回合收尾。
|
||||
func (s *Session) AppendTurnEnd(t TurnEnd) {
|
||||
if s.Path == "" {
|
||||
return
|
||||
}
|
||||
t.At = time.Now().UnixMilli()
|
||||
line, err := json.Marshal(turnEndRecord{Kind: turnEndKind, Reason: t.Reason, Error: t.Error, Step: t.Step, Ms: t.Ms, At: t.At})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.appendLine(append(line, '\n')); err != nil {
|
||||
return
|
||||
}
|
||||
s.TurnEnds = append(s.TurnEnds, t)
|
||||
}
|
||||
|
||||
// cursorKind 是游标记录在 JSONL 里的判别值。它不可能与消息撞车:消息一定有 role,
|
||||
// 游标一定没有。
|
||||
const cursorKind = "autogo.cursor"
|
||||
|
||||
// cursorRecord 一行 {"kind":"autogo.cursor","keep":3}:把此刻已积累的活跃视图
|
||||
// 截到前 keep 条,之后的读取继续往(缩短后的)视图上追加。
|
||||
//
|
||||
// 为什么不是直接改短文件(pigo/ZCode 的同一选择):会话文件是唯一事实源,靠重写
|
||||
// 维护历史就意味着「重写失败 = 历史没了」。回退这一步尤其危险——被截掉的那些消息
|
||||
// 是用户唯一可能想撤销的内容。改成追加一条游标之后,回退在磁盘上只是一个
|
||||
// 单行 append:写一半被杀,最坏结果是这行作废、回退没生效,而不是历史丢失。
|
||||
type cursorRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
|
||||
// parseCursor 判断这一行是不是游标。先用一次便宜的字节查找筛掉绝大多数消息行
|
||||
// (消息行可能几百 KB,逐行再解一遍 JSON 太浪费)。
|
||||
func parseCursor(line []byte) (int, bool) {
|
||||
if !bytes.Contains(line, []byte(`"`+cursorKind+`"`)) {
|
||||
return 0, false
|
||||
}
|
||||
var c cursorRecord
|
||||
if json.Unmarshal(line, &c) != nil || c.Kind != cursorKind {
|
||||
return 0, false
|
||||
}
|
||||
if c.Keep < 0 {
|
||||
c.Keep = 0
|
||||
}
|
||||
return c.Keep, true
|
||||
}
|
||||
|
||||
// New 创建带时间戳的新会话文件名。
|
||||
func New() (*Session, error) {
|
||||
dir := Dir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{Path: newName(dir, time.Now().Format("20060102-150405"))}, nil
|
||||
}
|
||||
|
||||
// newMu 保护 issued:本进程已发出、可能还没落盘的会话路径。
|
||||
var (
|
||||
newMu sync.Mutex
|
||||
issued = map[string]bool{}
|
||||
)
|
||||
|
||||
// newName 在 dir 里挑一个没被占用的会话文件名。
|
||||
//
|
||||
// 名字只精确到秒,同一秒内连续新建会拿到同一个名字。会话文件是**第一条消息**才
|
||||
// 写盘的,所以只查磁盘会漏掉"两份都还没落盘"那种撞名——那时光看 Stat 分不出来,
|
||||
// 第二次"新建"经 LoadOrCreate 会把第一份(甚至某份已经写好消息的旧会话)当成
|
||||
// 自己的。因此两道都查:盘上已有的文件、本进程已发出的名字,撞上就依次加 -2/-3 后缀。
|
||||
func newName(dir, ts string) string {
|
||||
newMu.Lock()
|
||||
defer newMu.Unlock()
|
||||
path := filepath.Join(dir, ts+".jsonl")
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) && !issued[path] {
|
||||
issued[path] = true
|
||||
return path
|
||||
}
|
||||
path = filepath.Join(dir, fmt.Sprintf("%s-%d.jsonl", ts, i))
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeName 清理会话文件名(bot 渠道按 chat 命名,chat id 可能来自外部)。
|
||||
func sanitizeName(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if len(s) > 80 {
|
||||
s = s[:80]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// LoadOrCreate 打开具名会话;不存在则创建(bot 渠道按 chat 持久化记忆)。
|
||||
func LoadOrCreate(name string) (*Session, error) {
|
||||
dir := Dir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(dir, sanitizeName(name)+".jsonl")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Load(path)
|
||||
}
|
||||
return &Session{Path: path}, nil
|
||||
}
|
||||
|
||||
// Exists 报告具名会话是否已经落盘。后台通知等"会话之外的事"用它避免把
|
||||
// 已删除的会话重新创建出来。
|
||||
func Exists(name string) bool {
|
||||
if _, ok := SafeID(name); !ok {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(Dir(), name+".jsonl"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Latest 返回最近修改的会话;目录为空时新建。
|
||||
func Latest() (*Session, error) {
|
||||
dir := Dir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return New()
|
||||
}
|
||||
best := ""
|
||||
var bestMod time.Time
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".jsonl" {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err == nil && info.ModTime().After(bestMod) {
|
||||
bestMod, best = info.ModTime(), e.Name()
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return New()
|
||||
}
|
||||
return Load(filepath.Join(dir, best))
|
||||
}
|
||||
|
||||
// Load 重放 JSONL 文件恢复会话。
|
||||
func Load(path string) (*Session, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s := &Session{Path: path}
|
||||
// 用 jsonl.Each 而不是 bufio.Scanner:后者有 64KB 行长上限,超限会让**整个会话
|
||||
// 读不出来**(曾因一条 204KB 的 reasoning 消息导致会话在 WebUI 里消失)。
|
||||
err = jsonl.Each(f, func(line []byte) {
|
||||
if keep, ok := parseCursor(line); ok {
|
||||
// 游标只缩短活跃视图,被越过的消息收进 retired 原样留着(Rewrite 要写回去)。
|
||||
if keep < len(s.Messages) {
|
||||
s.Retired += len(s.Messages) - keep
|
||||
s.retired = append(s.retired, s.Messages[keep:]...)
|
||||
s.Messages = s.Messages[:keep]
|
||||
}
|
||||
return
|
||||
}
|
||||
if te, ok := parseTurnEnd(line); ok {
|
||||
s.TurnEnds = append(s.TurnEnds, te)
|
||||
return
|
||||
}
|
||||
var m llm.Message
|
||||
if json.Unmarshal(line, &m) != nil || m.Role == "" {
|
||||
// 坏行、或崩溃留下的半行:跳过。没有 role 的行不是一条消息——
|
||||
// 把它当空消息收进视图,会让下一次请求带着一条非法消息去撞 API。
|
||||
return
|
||||
}
|
||||
s.Messages = append(s.Messages, m)
|
||||
})
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Append 追加一条消息并落盘(追加写,兼容 tail)。
|
||||
func (s *Session) Append(m llm.Message) error {
|
||||
s.Messages = append(s.Messages, m)
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 换行是内容的一部分:少了它,多条消息会被写成**一行**,整份会话解析不出来
|
||||
// (曾经就是这么把自己改坏的)。
|
||||
return s.appendLine(append(b, '\n'))
|
||||
}
|
||||
|
||||
// AppendAll 一次打开文件追加多条(撤销回退时接回一段历史用)。
|
||||
//
|
||||
// 只有 Append 可用时也能写,但撤销一次回退要往文件里塞回几十条消息,
|
||||
// 逐条 open/close 既慢又给中途崩溃留更多口子。
|
||||
func (s *Session) AppendAll(msgs []llm.Message) error {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, m := range msgs {
|
||||
line, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if err := s.appendLine(b.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Messages = append(s.Messages, msgs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendLine 把一段已经带换行的 JSONL 追加到会话文件。Path 为空 = 临时会话,只改内存。
|
||||
func (s *Session) appendLine(payload []byte) error {
|
||||
if s.Path == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := os.OpenFile(s.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// RetireTo 把活跃视图截到前 keep 条,并在文件末尾追加一条游标记录。
|
||||
//
|
||||
// 与 Truncate 的分工是刻意的:RetireTo 是**回退**(历史留在盘上、可撤销、不进上下文),
|
||||
// Truncate/Rewrite 是**清空**(/clear 那种"这些内容我不要了"的语义,真的把文件改短)。
|
||||
// 写盘失败时内存一点没动——界面与文件仍然一致。
|
||||
func (s *Session) RetireTo(keep int) error {
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if keep >= len(s.Messages) {
|
||||
return nil // 没东西可退役,也不留一条无意义的游标
|
||||
}
|
||||
line, err := json.Marshal(cursorRecord{Kind: cursorKind, Keep: keep})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.appendLine(append(line, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Retired += len(s.Messages) - keep
|
||||
s.retired = append(s.retired, s.Messages[keep:]...)
|
||||
s.Messages = s.Messages[:keep]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rewrite 用当前内存中的消息重写 JSONL 文件。
|
||||
//
|
||||
// 这是**破坏性**的一条:写完之后文件里只有活跃视图,游标与已退役的历史一起没了。
|
||||
// 所以回退走 RetireTo,这里只留给"我确实不要这些内容了"的 /clear,以及更新已有
|
||||
// 消息字段的场合。
|
||||
//
|
||||
// 必须 tmp+rename 原子替换:原来直接 os.Create 目标文件再逐条写,进程在中间被杀
|
||||
// (或磁盘写满)会把整条会话截成半截——而会话文件是唯一事实源。
|
||||
func (s *Session) Rewrite() error {
|
||||
if s.Path == "" {
|
||||
return nil
|
||||
}
|
||||
var b bytes.Buffer
|
||||
// 先写退役的历史,再写一条 keep=0 的游标把它们从活跃视图里隔开,最后写当前视图。
|
||||
// 不这么做的话,任何一次 Rewrite(例如给图片回填缓存文件名)都会把回退留下的
|
||||
// 历史抹掉——文件是"更新一条消息"的载体,不该顺手删掉别的东西。
|
||||
emit := func(m llm.Message) error {
|
||||
line, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
return nil
|
||||
}
|
||||
for _, m := range s.retired {
|
||||
if err := emit(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(s.retired) > 0 {
|
||||
cur, err := json.Marshal(cursorRecord{Kind: cursorKind, Keep: 0})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(cur)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
for _, m := range s.Messages {
|
||||
if err := emit(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, te := range s.TurnEnds {
|
||||
line, err := json.Marshal(turnEndRecord{Kind: turnEndKind, Reason: te.Reason, Error: te.Error, Step: te.Step, Ms: te.Ms, At: te.At})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
tmp := s.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.Path)
|
||||
}
|
||||
|
||||
// Reset 清空一切:活跃视图、退役历史、回合账。
|
||||
//
|
||||
// /clear 要的是"这些内容我不要了",所以退役的那部分也得跟着丢——否则 Rewrite
|
||||
// 会把它们原样写回去,"清空"就只剩清空视图、文件却越清越肥。
|
||||
func (s *Session) Reset() {
|
||||
s.Messages = nil
|
||||
s.retired = nil
|
||||
s.Retired = 0
|
||||
s.TurnEnds = nil
|
||||
}
|
||||
|
||||
// SaveState 原子写一个状态文件(借鉴 picoclaw state.go 的 tmp+rename)。
|
||||
func SaveState(path string, v any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadState 读回 SaveState 写的状态文件。文件缺失或损坏返回错误,
|
||||
// 由调用方决定是否降级(如项目计划缺失 = 空计划,不是致命)。
|
||||
func LoadState(path string, v any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// String 摘要信息。
|
||||
func (s *Session) String() string {
|
||||
return fmt.Sprintf("session %s (%d messages)", filepath.Base(s.Path), len(s.Messages))
|
||||
}
|
||||
|
||||
// CountPrompts 返回用户消息条数,即"第几个 prompt"意义上的回合数。
|
||||
//
|
||||
// 口径只有一处定义(连同 PromptBoundary),因为留档的回合号与回退的切点
|
||||
// 必须是同一套数法:否则 `a2go rewind 3` 回到的不是第三次提问之前。
|
||||
// 数的是 RoleUser 全部消息(含只带图片的),与写入时的计数规则一致。
|
||||
func CountPrompts(msgs []llm.Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if m.Role == llm.RoleUser && !m.Synthetic {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PromptBoundary 返回第 n 个用户消息的下标,也就是"回到第 n 个 prompt 之前"
|
||||
// 应当保留的长度;找不到(n 越界)返回 -1。
|
||||
//
|
||||
// 切点落在用户消息上是有意的:切在 assistant 的 tool_use 与它的 tool_result
|
||||
// 之间会留下没有配对的 tool_use,下一次请求被 API 直接拒掉。
|
||||
func PromptBoundary(msgs []llm.Message, n int) int {
|
||||
count := 0
|
||||
for i := range msgs {
|
||||
if msgs[i].Role != llm.RoleUser || msgs[i].Synthetic {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count == n {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TailFromPrompt 返回第 n 个用户 prompt 起的尾部**副本**与切点下标(找不到返回 -1)。
|
||||
//
|
||||
// 它只读不动任何东西,这是刻意的顺序约束:调用方必须先用这份副本归档、成功后
|
||||
// 再 RetireTo。反过来就会出现"消息已经从活跃视图摘掉、归档却失败"的窗口——
|
||||
// 那正是这次改造要关掉的东西。
|
||||
func (s *Session) TailFromPrompt(n int) (idx int, dropped []llm.Message) {
|
||||
idx = PromptBoundary(s.Messages, n)
|
||||
if idx < 0 {
|
||||
return -1, nil
|
||||
}
|
||||
return idx, append([]llm.Message(nil), s.Messages[idx:]...)
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
)
|
||||
|
||||
// TestMain 把整个包的 HOME 钉在临时目录里。
|
||||
//
|
||||
// 这不是形式主义:New() 会往 ~/.autogo/sessions 真的建文件,而漏写一行
|
||||
// t.Setenv("HOME", …) 的测试就会把垃圾塞进用户自己的会话目录(本项目真发生过)。
|
||||
// 单个用例的隔离仍然保留(有的用例要换 HOME),但兜底不该依赖每个人都记得。
|
||||
func TestMain(m *testing.M) {
|
||||
home, err := os.MkdirTemp("", "autogo-session-test-home")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Setenv("HOME", home)
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(home)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestRoundTripWithImages:含图片的消息 JSONL 持久化后完整恢复。
|
||||
func TestRoundTripWithImages(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "take a screenshot"},
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "computer", Input: `{"action":"screenshot"}`}}},
|
||||
{Role: llm.RoleTool, ToolCallID: "c1", Content: "screenshot saved",
|
||||
Images: []llm.Image{{Path: "/tmp/autogo-shot-1.png"}}},
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if err := s.Append(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 3 {
|
||||
t.Fatalf("want 3 messages, got %d", len(got.Messages))
|
||||
}
|
||||
last := got.Messages[2]
|
||||
if len(last.Images) != 1 || last.Images[0].Path != "/tmp/autogo-shot-1.png" {
|
||||
t.Fatalf("images not persisted: %+v", last.Images)
|
||||
}
|
||||
if filepath.Base(s.Path) == "" {
|
||||
t.Fatal("session path empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTripTrace:trace 字段(时间戳/耗时/步骤序号)随 JSONL 完整往返。
|
||||
func TestRoundTripTrace(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := llm.Message{
|
||||
Role: llm.RoleAssistant, Content: "done",
|
||||
StartedAt: 1700000000000, DurationMs: 1234, Step: 5,
|
||||
}
|
||||
if err := s.Append(in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 1 {
|
||||
t.Fatalf("want 1 message, got %d", len(got.Messages))
|
||||
}
|
||||
last := got.Messages[0]
|
||||
if last.StartedAt != in.StartedAt || last.DurationMs != in.DurationMs || last.Step != in.Step {
|
||||
t.Fatalf("trace not persisted: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadLegacyLineWithoutTrace:旧版 JSONL(无 trace 字段)必须能读,零值且不报错。
|
||||
func TestLoadLegacyLineWithoutTrace(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := `{"role":"assistant","content":"old","tool_calls":[{"id":"c1","name":"read","input":"{}"}]}` + "\n"
|
||||
if err := os.WriteFile(s.Path, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy line must load without error: %v", err)
|
||||
}
|
||||
if len(got.Messages) != 1 {
|
||||
t.Fatalf("want 1 message, got %d", len(got.Messages))
|
||||
}
|
||||
m := got.Messages[0]
|
||||
if m.Content != "old" || len(m.ToolCalls) != 1 {
|
||||
t.Fatalf("legacy content lost: %+v", m)
|
||||
}
|
||||
if m.StartedAt != 0 || m.DurationMs != 0 || m.Step != 0 {
|
||||
t.Fatalf("legacy message should have zero trace, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadToleratesHugeLine:**回归**——单行超过 bufio.Scanner 默认 64KB 上限时,
|
||||
// 整份会话仍然要能读出来。
|
||||
//
|
||||
// 事故现场:20260916 的一个会话里有一条 assistant 消息带 204KB 的 reasoning,
|
||||
// 于是 Load 返回 "bufio.Scanner: token too long",列表把读不出来的会话直接跳过,
|
||||
// 整个会话在 WebUI 里消失、历史也打不开。
|
||||
func TestLoadToleratesHugeLine(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
dir := Dir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "big.jsonl")
|
||||
|
||||
huge := strings.Repeat("r", 300<<10) // 300KB 的推理文本
|
||||
lines := []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "开始"},
|
||||
{Role: llm.RoleAssistant, Reasoning: huge, Content: "想完了"},
|
||||
{Role: llm.RoleAssistant, Content: "结束"},
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
for _, m := range lines {
|
||||
if err := enc.Encode(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// 先确认这份文件确实会踩到 Scanner 的默认上限(否则这个测试是假的)。
|
||||
raw, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sc := bufio.NewScanner(raw)
|
||||
for sc.Scan() {
|
||||
}
|
||||
if sc.Err() == nil {
|
||||
t.Skip("构造的数据没超过 Scanner 上限,测不出问题")
|
||||
}
|
||||
_ = raw.Close()
|
||||
|
||||
sess, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("超长行不该让整份会话读不出来: %v", err)
|
||||
}
|
||||
if len(sess.Messages) != 3 {
|
||||
t.Fatalf("消息数 = %d, want 3", len(sess.Messages))
|
||||
}
|
||||
if len(sess.Messages[1].Reasoning) != len(huge) {
|
||||
t.Errorf("超长字段被截断了: %d", len(sess.Messages[1].Reasoning))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireToLeavesMemoryIntactWhenWriteFails:游标写不进盘时,内存里的活跃视图
|
||||
// 一条都不能少——否则就是"界面已经短了、盘上还是全长"的分裂状态,用户下一次
|
||||
// 发消息会基于一个不存在的历史继续。
|
||||
func TestRetireToLeavesMemoryIntactWhenWriteFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := &Session{Path: filepath.Join(dir, "s.jsonl")}
|
||||
for i := 0; i < 4; i++ {
|
||||
s.Messages = append(s.Messages, llm.Message{Role: llm.RoleUser, Content: "x"})
|
||||
}
|
||||
if err := s.Rewrite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 注意是文件本身只读,不是目录:追加一条游标只需要文件写权限,只读目录拦不住它
|
||||
// (这本身是好事——回退在目录被锁的情况下仍然能落盘)。
|
||||
if err := os.Chmod(s.Path, 0o444); err != nil {
|
||||
t.Skipf("造不出只读文件:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(s.Path, 0o644) })
|
||||
|
||||
if err := s.RetireTo(2); err == nil {
|
||||
t.Fatal("文件只读,追加游标该失败")
|
||||
}
|
||||
if len(s.Messages) != 4 || s.Retired != 0 {
|
||||
t.Fatalf("失败后内存视图 %d 条 / 退役 %d,want 4/0", len(s.Messages), s.Retired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteIsAtomicReplace:Rewrite 走 tmp+rename,成功后不留临时文件,
|
||||
// 文件行数与内存条数一致(会话文件是唯一事实源)。
|
||||
func TestRewriteIsAtomicReplace(t *testing.T) {
|
||||
s := &Session{Path: filepath.Join(t.TempDir(), "s.jsonl")}
|
||||
s.Messages = []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "一"},
|
||||
{Role: llm.RoleAssistant, Content: "二"},
|
||||
}
|
||||
if err := s.Rewrite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(s.Path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("临时文件没被 rename 收掉:%v", err)
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 2 || got.Messages[1].Content != "二" {
|
||||
t.Fatalf("重写后的会话 = %+v", got.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTailFromPrompt:切点与尾部副本。只读——归档必须在退役之前发生。
|
||||
func TestTailFromPrompt(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Messages = []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "第一个"},
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "t1", Name: "bash"}}},
|
||||
{Role: llm.RoleTool, ToolCallID: "t1", Content: "ok"},
|
||||
{Role: llm.RoleUser, Content: "第二个"},
|
||||
{Role: llm.RoleAssistant, Content: "答完"},
|
||||
}
|
||||
before := len(s.Messages)
|
||||
idx, dropped := s.TailFromPrompt(2)
|
||||
if idx != 3 || len(dropped) != 2 {
|
||||
t.Fatalf("idx=%d dropped=%d,want 3/2", idx, len(dropped))
|
||||
}
|
||||
if len(s.Messages) != before {
|
||||
t.Fatalf("TailFromPrompt 不该改动会话,现在 %d 条", len(s.Messages))
|
||||
}
|
||||
if _, d := s.TailFromPrompt(9); d != nil {
|
||||
t.Fatal("越界的号返回了尾部")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireToKeepsHistory:回退只追加一条游标——活跃视图短了,文件一行没少。
|
||||
// 这是整套设计的核心断言:**被回退掉的内容仍然在盘上**。
|
||||
func TestRetireToKeepsHistory(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 4; i++ {
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: string(rune('a' + i))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.RetireTo(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Messages) != 2 || s.Retired != 2 {
|
||||
t.Fatalf("内存视图 %d 条 / 退役 %d 条,want 2/2", len(s.Messages), s.Retired)
|
||||
}
|
||||
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 2 {
|
||||
t.Fatalf("重读后活跃视图 = %d 条,want 2", len(got.Messages))
|
||||
}
|
||||
if got.Retired != 2 {
|
||||
t.Fatalf("重读后退役计数 = %d,want 2", got.Retired)
|
||||
}
|
||||
// 文件里还得数得出 4 条消息 + 1 条游标:回退没删任何东西。
|
||||
lines := rawLines(t, s.Path)
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("文件行数 = %d,want 5(4 条消息 + 1 条游标)", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireThenAppend:回退之后接着说话,新消息排在**缩短后的**视图上,
|
||||
// 而不是被文件里那些退役消息的位置带跑。
|
||||
func TestRetireThenAppend(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir()) // 不隔离就会往用户真实的 ~/.autogo/sessions 里写垃圾
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{"u1", "a1", "u2", "a2"} {
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.RetireTo(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "u3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var view []string
|
||||
for _, m := range got.Messages {
|
||||
view = append(view, m.Content)
|
||||
}
|
||||
if strings.Join(view, ",") != "u1,a1,u3" {
|
||||
t.Fatalf("活跃视图 = %v,want [u1 a1 u3]", view)
|
||||
}
|
||||
if got.Retired != 2 {
|
||||
t.Fatalf("退役计数 = %d, want 2", got.Retired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHalfWrittenCursorIsInert:崩溃留在文件里的半行游标必须**什么都不做**。
|
||||
// 选这个失败方向是有道理的:最坏结果是这次回退没生效(用户再按一次),
|
||||
// 而不是历史丢了。
|
||||
func TestHalfWrittenCursorIsInert(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{"u1", "a1", "u2"} {
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(s.Path, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 写到一半就断:没有换行、JSON 不完整。
|
||||
_, _ = f.WriteString(`{"kind":"autogo.curso`)
|
||||
_ = f.Close()
|
||||
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("半行不该让整份会话读不出来:%v", err)
|
||||
}
|
||||
if len(got.Messages) != 3 || got.Retired != 0 {
|
||||
t.Fatalf("活跃视图 = %d 条 / 退役 %d,want 3/0", len(got.Messages), got.Retired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSkipsRolelessJunk:没有 role 的行不是消息。旧实现会把任意 JSON 对象
|
||||
// 收成一条空消息,于是崩溃残留/手工编辑留下的垃圾行会变成"发给 API 的非法消息"。
|
||||
func TestLoadSkipsRolelessJunk(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "junk.jsonl")
|
||||
body := `{"role":"user","content":"真消息"}
|
||||
{"whatever":1}
|
||||
not json at all
|
||||
{"role":"assistant","content":"第二条"}
|
||||
{"kind":"autogo.cursor","keep":1}
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 1 || got.Messages[0].Content != "真消息" {
|
||||
var view []string
|
||||
for _, m := range got.Messages {
|
||||
view = append(view, m.Content)
|
||||
}
|
||||
t.Fatalf("活跃视图 = %v,want 只剩「真消息」", view)
|
||||
}
|
||||
if got.Retired != 1 {
|
||||
t.Fatalf("退役计数 = %d, want 1", got.Retired)
|
||||
}
|
||||
}
|
||||
|
||||
// rawLines 数文件里的非空行(绕开 Load 的视图语义)。
|
||||
func rawLines(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRewriteKeepsRetiredHistory:Rewrite 不能顺手抹掉回退留下的历史。
|
||||
//
|
||||
// 这不是假想的坑:collectToolImages 给图片回填缓存文件名之后就会 Rewrite 整个
|
||||
// 会话文件。如果那次重写只写活跃视图,一次图片缓存就把刚做的回退历史删干净了。
|
||||
func TestRewriteKeepsRetiredHistory(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{"m1", "m2", "m3", "m4"} {
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.RetireTo(2); err != nil { // 回退:m3/m4 退役
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟"更新已有消息的字段后整体重写"
|
||||
s.Messages[0].Content = "m1-改过"
|
||||
if err := s.Rewrite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 2 || got.Messages[0].Content != "m1-改过" {
|
||||
t.Fatalf("活跃视图 = %+v", got.Messages)
|
||||
}
|
||||
if got.Retired != 2 {
|
||||
t.Fatalf("退役计数 = %d, want 2(历史必须在)", got.Retired)
|
||||
}
|
||||
if n := len(rawLines(t, s.Path)); n != 5 {
|
||||
t.Fatalf("文件行数 = %d, want 5(2 退役 + 游标 + 2 活跃)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetTrulyClears:/clear 走 Reset,退役历史与回合账一起清掉——
|
||||
// 否则"清空"只清视图,文件反而越清越肥。
|
||||
func TestResetTrulyClears(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{"a", "b", "c"} {
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.RetireTo(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AppendTurnEnd(TurnEnd{Reason: "completed", Step: 3, Ms: 12})
|
||||
s.Reset()
|
||||
if err := s.Rewrite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(rawLines(t, s.Path)); n != 0 {
|
||||
t.Fatalf("清空后文件还有 %d 行", n)
|
||||
}
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Messages) != 0 || got.Retired != 0 || len(got.TurnEnds) != 0 {
|
||||
t.Fatalf("清空不彻底:%d 条 / 退役 %d / 回合账 %d", len(got.Messages), got.Retired, len(got.TurnEnds))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEndRoundTrip:回合结束原因必须能在会话文件里读到。
|
||||
// 只导出 JSONL 做取证时,"为什么停"不能靠猜。
|
||||
func TestTurnEndRoundTrip(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "干活"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AppendTurnEnd(TurnEnd{Reason: "max_turns", Step: 120, Ms: 987654})
|
||||
s.AppendTurnEnd(TurnEnd{Reason: "failed", Error: "openai: 模型回复被截断", Step: 33, Ms: 62061})
|
||||
|
||||
got, err := Load(s.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.TurnEnds) != 2 {
|
||||
t.Fatalf("回合账 = %+v, want 2 条", got.TurnEnds)
|
||||
}
|
||||
if got.TurnEnds[0].Reason != "max_turns" || got.TurnEnds[0].Step != 120 || got.TurnEnds[0].Ms != 987654 {
|
||||
t.Fatalf("第一条不对:%+v", got.TurnEnds[0])
|
||||
}
|
||||
if !strings.Contains(got.TurnEnds[1].Error, "截断") {
|
||||
t.Fatalf("错误原因没留下:%+v", got.TurnEnds[1])
|
||||
}
|
||||
if got.TurnEnds[0].At == 0 {
|
||||
t.Fatal("回合账没带时刻")
|
||||
}
|
||||
// 回合账不是消息:不能混进上下文。
|
||||
if len(got.Messages) != 1 {
|
||||
t.Fatalf("上下文里混进了非消息行:%d 条", len(got.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyntheticPromptNotCounted:后台通知不是人工提问——CountPrompts 与
|
||||
// PromptBoundary 都不应把它算进去,否则 rewind 的号会整体偏移。
|
||||
func TestSyntheticPromptNotCounted(t *testing.T) {
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "first"},
|
||||
{Role: llm.RoleUser, Content: "[background] done", Synthetic: true},
|
||||
{Role: llm.RoleUser, Content: "second"},
|
||||
}
|
||||
if got := CountPrompts(msgs); got != 2 {
|
||||
t.Fatalf("CountPrompts = %d, want 2", got)
|
||||
}
|
||||
if idx := PromptBoundary(msgs, 1); idx != 0 {
|
||||
t.Fatalf("boundary 1 = %d, want 0", idx)
|
||||
}
|
||||
if idx := PromptBoundary(msgs, 2); idx != 2 {
|
||||
t.Fatalf("boundary 2 = %d, want 2", idx)
|
||||
}
|
||||
if idx := PromptBoundary(msgs, 3); idx != -1 {
|
||||
t.Fatalf("boundary 3 = %d, want -1 (synthetic is not prompt 3)", idx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package session
|
||||
|
||||
// 会话标题:sidecar 文件 <id>.title(与会话 jsonl 同目录)。
|
||||
// 标题本地派生自用户首条消息,零请求、零延迟;TUI 与 WebUI 共用本文件。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SafeID 拒绝路径穿越,标题文件只允许落在会话目录内。
|
||||
func SafeID(id string) (string, bool) {
|
||||
if id == "" || strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") {
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// TitlePath 标题 sidecar 文件路径。
|
||||
func TitlePath(id string) string {
|
||||
return filepath.Join(Dir(), id+".title")
|
||||
}
|
||||
|
||||
// LoadTitle 读取标题;无标题返回 ""。
|
||||
func LoadTitle(id string) string {
|
||||
safe, ok := SafeID(id)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(TitlePath(safe))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// SaveTitle 写入标题(去首尾空白,上限 60 字)。
|
||||
func SaveTitle(id, title string) error {
|
||||
safe, ok := SafeID(id)
|
||||
if !ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
title = TruncateRunes(strings.TrimSpace(title), 60)
|
||||
if title == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
if err := os.MkdirAll(Dir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(TitlePath(safe), []byte(title), 0o644)
|
||||
}
|
||||
|
||||
// TruncateRunes 按 rune 截断。
|
||||
func TruncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// SanitizeTitle 清洗标题:去空白、去包围引号,折叠空白,截断 24 字。
|
||||
func SanitizeTitle(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, `"'「」『』【】<>`)
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
return TruncateRunes(s, 24)
|
||||
}
|
||||
|
||||
// DeriveTitle 从用户首条消息本地派生会话标题:不发请求、零延迟、无失败路径。
|
||||
//
|
||||
// 此前为标题单独发一次模型调用,代价远大于收益:该模型上常达 ~27s,与主回合并发时
|
||||
// 极易超时(30s 上限),导致长回合的会话静默丢标题。而标题只是会话列表里的速记,
|
||||
// 用「用户诉求的首行」就足够辨认,没必要为一个标签花掉一次完整往返。
|
||||
func DeriveTitle(userMsg string) string {
|
||||
s := strings.TrimSpace(userMsg)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// 多行输入取首行——那通常是主诉求,后续多为补充细节或粘贴内容。
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// 去掉 markdown 装饰:标题符号、引用、列表符、代码块围栏。
|
||||
s = strings.TrimLeft(s, "#>*-`~ ")
|
||||
return SanitizeTitle(s)
|
||||
}
|
||||
Reference in new issue
Block a user