diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index 6ef580ed..321cc7b0 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -149,13 +149,33 @@ func cloneStringSlice(items []string) []string { return append([]string(nil), items...) } -// extractJSONObject 从模型响应中提取最外层 JSON 对象,容忍前后噪音。 +// extractJSONObject 从模型响应中提取首个满足 compact 协议的 JSON 对象,容忍前后噪音。 func extractJSONObject(text string) (string, error) { - start := strings.Index(text, "{") + start := strings.IndexByte(text, '{') if start < 0 { return "", errors.New("runtime: compact summary response does not contain a JSON object") } + for { + candidate, err := extractJSONObjectCandidate(text, start) + if err == nil { + if _, decodeErr := decodeCompactSummaryResponse(candidate); decodeErr == nil { + return candidate, nil + } + } + + next := strings.IndexByte(text[start+1:], '{') + if next < 0 { + break + } + start += next + 1 + } + + return "", errors.New("runtime: compact summary response does not contain a valid compact JSON object") +} + +// extractJSONObjectCandidate 从给定起点抽取平衡的 JSON 对象片段。 +func extractJSONObjectCandidate(text string, start int) (string, error) { depth := 0 inString := false escaped := false diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index ac2c908a..6c389467 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -258,3 +258,20 @@ func TestParseCompactSummaryOutputRejectsUnknownTaskStateField(t *testing.T) { t.Fatal("expected unknown task_state field to be rejected") } } + +func TestParseCompactSummaryOutputSkipsNonCompactJSONPreface(t *testing.T) { + t.Parallel() + + content := strings.Join([]string{ + `preface with braces {"hint":"not compact"}`, + `{"task_state":{"goal":"g","progress":[],"open_items":[],"next_step":"","blockers":[],"key_artifacts":[],"decisions":[],"user_constraints":[]},"display_summary":"[compact_summary]\nok"}`, + }, "\n") + + output, err := parseCompactSummaryOutput(content) + if err != nil { + t.Fatalf("expected parser to recover valid compact payload, got %v", err) + } + if output.TaskState.Goal != "g" { + t.Fatalf("expected parsed goal, got %+v", output.TaskState) + } +} diff --git a/internal/session/store.go b/internal/session/store.go index 2ea97169..a5018040 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -81,7 +81,7 @@ func (s *JSONStore) Save(ctx context.Context, session *Session) error { return err } - session.TaskState = NormalizeTaskState(session.TaskState) + session.TaskState = normalizeAndClampTaskState(session.TaskState) s.mu.Lock() defer s.mu.Unlock() @@ -236,29 +236,59 @@ func validateSessionSchema(session Session) error { // decodeStoredSession 严格校验持久化会话所需字段,并拒绝缺少 schema_version 或 task_state 的旧数据。 func decodeStoredSession(data []byte) (Session, error) { - var envelope map[string]json.RawMessage - if err := json.Unmarshal(data, &envelope); err != nil { + type storedSession struct { + SchemaVersion *int `json:"schema_version"` + ID string `json:"id"` + Title string `json:"title"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + TaskState *TaskState `json:"task_state"` + Messages []providertypes.Message `json:"messages"` + TokenInput int `json:"token_input_total,omitempty"` + TokenOutput int `json:"token_output_total,omitempty"` + } + + var stored storedSession + if err := json.Unmarshal(data, &stored); err != nil { return Session{}, err } - if _, ok := envelope["schema_version"]; !ok { + if stored.SchemaVersion == nil { return Session{}, errors.New("missing required field schema_version") } - if _, ok := envelope["task_state"]; !ok { + if stored.TaskState == nil { return Session{}, errors.New("missing required field task_state") } - var session Session - if err := json.Unmarshal(data, &session); err != nil { - return Session{}, err + session := Session{ + SchemaVersion: *stored.SchemaVersion, + ID: stored.ID, + Title: stored.Title, + Provider: stored.Provider, + Model: stored.Model, + CreatedAt: stored.CreatedAt, + UpdatedAt: stored.UpdatedAt, + Workdir: stored.Workdir, + TaskState: *stored.TaskState, + Messages: stored.Messages, + TokenInputTotal: stored.TokenInput, + TokenOutputTotal: stored.TokenOutput, } if err := validateSessionSchema(session); err != nil { return Session{}, err } - session.TaskState = NormalizeTaskState(session.TaskState) + session.TaskState = normalizeAndClampTaskState(session.TaskState) return session, nil } +// normalizeAndClampTaskState 先规范化再限幅,保证持久化前后的 task_state 行为一致。 +func normalizeAndClampTaskState(state TaskState) TaskState { + return ClampTaskStateBoundaries(NormalizeTaskState(state)) +} + // decodeStoredSummary 只解析会话列表所需的摘要元数据,避免为列表视图反序列化完整消息历史。 func decodeStoredSummary(data []byte) (Summary, error) { var stored struct { diff --git a/internal/session/store_test.go b/internal/session/store_test.go index dccb6571..5a0dc1e6 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -679,6 +679,111 @@ func TestDecodeStoredSummaryUsesLightweightMetadataPath(t *testing.T) { } } +func TestJSONStoreSaveClampsOversizedTaskState(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + progress := make([]string, 0, taskStateMaxListItems+8) + for i := 0; i < taskStateMaxListItems+8; i++ { + progress = append(progress, strings.Repeat("p", taskStateMaxListItemChars-4)+buildIndexedSuffix(i)) + } + session := &Session{ + SchemaVersion: CurrentSchemaVersion, + ID: "task-state-clamp-save", + Title: "Clamp Save", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + TaskState: TaskState{ + Goal: strings.Repeat("g", taskStateMaxFieldChars+50), + NextStep: strings.Repeat("n", taskStateMaxFieldChars+50), + Progress: progress, + OpenItems: progress, + }, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session: %v", err) + } + + if len([]rune(session.TaskState.Goal)) != taskStateMaxFieldChars { + t.Fatalf("expected goal to be clamped to %d runes, got %d", taskStateMaxFieldChars, len([]rune(session.TaskState.Goal))) + } + if len(session.TaskState.Progress) != taskStateMaxListItems { + t.Fatalf("expected progress list clamped to %d, got %d", taskStateMaxListItems, len(session.TaskState.Progress)) + } + if len([]rune(session.TaskState.Progress[0])) != taskStateMaxListItemChars { + t.Fatalf( + "expected progress item clamped to %d runes, got %d", + taskStateMaxListItemChars, + len([]rune(session.TaskState.Progress[0])), + ) + } +} + +func TestJSONStoreLoadClampsOversizedTaskState(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + payload := strings.Join([]string{ + `{`, + ` "schema_version": 1,`, + ` "id": "task-state-clamp-load",`, + ` "title": "Clamp Load",`, + ` "created_at": "2026-04-13T08:00:00Z",`, + ` "updated_at": "2026-04-13T09:00:00Z",`, + ` "task_state": {`, + ` "goal": "` + strings.Repeat("g", taskStateMaxFieldChars+30) + `",`, + ` "progress": [` + buildQuotedRepeatedWithIndex("p", taskStateMaxListItemChars+30, taskStateMaxListItems+3) + `],`, + ` "open_items": [],`, + ` "next_step": "",`, + ` "blockers": [],`, + ` "key_artifacts": [],`, + ` "decisions": [],`, + ` "user_constraints": [],`, + ` "last_updated_at": "2026-04-13T09:00:00Z"`, + ` },`, + ` "messages": []`, + `}`, + }, "\n") + mustWriteSessionFile( + t, + filepath.Join(sessionDirectory(baseDir, workspaceRoot), "task-state-clamp-load.json"), + payload, + ) + + loaded, err := store.Load(context.Background(), "task-state-clamp-load") + if err != nil { + t.Fatalf("load session: %v", err) + } + if len([]rune(loaded.TaskState.Goal)) != taskStateMaxFieldChars { + t.Fatalf("expected loaded goal to be clamped to %d runes, got %d", taskStateMaxFieldChars, len([]rune(loaded.TaskState.Goal))) + } + if len(loaded.TaskState.Progress) != taskStateMaxListItems { + t.Fatalf("expected loaded progress list clamped to %d, got %d", taskStateMaxListItems, len(loaded.TaskState.Progress)) + } +} + +func buildQuotedRepeatedWithIndex(ch string, itemLen int, count int) string { + items := make([]string, 0, count) + for i := 0; i < count; i++ { + items = append(items, `"`+strings.Repeat(ch, itemLen-4)+buildIndexedSuffix(i)+`"`) + } + return strings.Join(items, ",") +} + +func buildIndexedSuffix(index int) string { + chars := []rune("abcdefghijklmnopqrstuvwxyz0123456789") + hi := chars[(index/len(chars))%len(chars)] + lo := chars[index%len(chars)] + return string([]rune{hi, lo, 'x', 'x'}) +} + func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/session/task_state.go b/internal/session/task_state.go index de96b8a7..76c2f2fd 100644 --- a/internal/session/task_state.go +++ b/internal/session/task_state.go @@ -8,6 +8,13 @@ import ( const ( // CurrentSchemaVersion 表示当前会话持久化结构的唯一合法版本。 CurrentSchemaVersion = 1 + + // taskStateMaxFieldChars 限制 TaskState 单值字段的最大字符数,避免异常大文本污染持久化与后续 prompt。 + taskStateMaxFieldChars = 2000 + // taskStateMaxListItems 限制 TaskState 列表字段的最大条目数,避免模型输出超大数组导致上下文膨胀。 + taskStateMaxListItems = 32 + // taskStateMaxListItemChars 限制 TaskState 列表单条目的最大字符数,避免单项异常放大。 + taskStateMaxListItemChars = 400 ) // TaskState 表示会话级、可持久化的任务续航状态。 @@ -59,6 +66,19 @@ func NormalizeTaskState(state TaskState) TaskState { return state } +// ClampTaskStateBoundaries 对 TaskState 做尺寸与数量限幅,避免持久化状态无限增长。 +func ClampTaskStateBoundaries(state TaskState) TaskState { + state.Goal = truncateRunes(state.Goal, taskStateMaxFieldChars) + state.NextStep = truncateRunes(state.NextStep, taskStateMaxFieldChars) + state.Progress = truncateTaskStateList(state.Progress) + state.OpenItems = truncateTaskStateList(state.OpenItems) + state.Blockers = truncateTaskStateList(state.Blockers) + state.KeyArtifacts = truncateTaskStateList(state.KeyArtifacts) + state.Decisions = truncateTaskStateList(state.Decisions) + state.UserConstraints = truncateTaskStateList(state.UserConstraints) + return state +} + // normalizeTaskStateList 对任务状态中的字符串列表做去空、去重并保留顺序。 func normalizeTaskStateList(items []string) []string { if len(items) == 0 { @@ -84,3 +104,30 @@ func normalizeTaskStateList(items []string) []string { } return result } + +// truncateTaskStateList 在保持顺序前提下裁剪列表长度与每项字符数。 +func truncateTaskStateList(items []string) []string { + if len(items) == 0 { + return nil + } + if len(items) > taskStateMaxListItems { + items = items[:taskStateMaxListItems] + } + result := make([]string, 0, len(items)) + for _, item := range items { + result = append(result, truncateRunes(item, taskStateMaxListItemChars)) + } + return result +} + +// truncateRunes 按 rune 长度截断字符串,避免截断多字节字符。 +func truncateRunes(value string, limit int) string { + if limit <= 0 || value == "" { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} diff --git a/internal/session/task_state_test.go b/internal/session/task_state_test.go new file mode 100644 index 00000000..69416f9e --- /dev/null +++ b/internal/session/task_state_test.go @@ -0,0 +1,69 @@ +package session + +import ( + "strings" + "testing" +) + +func TestClampTaskStateBoundariesTruncatesFieldsAndLists(t *testing.T) { + t.Parallel() + + input := TaskState{ + Goal: strings.Repeat("g", taskStateMaxFieldChars+10), + NextStep: strings.Repeat("n", taskStateMaxFieldChars+5), + Progress: []string{strings.Repeat("p", taskStateMaxListItemChars+10)}, + OpenItems: []string{strings.Repeat("o", taskStateMaxListItemChars+10)}, + Blockers: []string{strings.Repeat("b", taskStateMaxListItemChars+10)}, + KeyArtifacts: []string{strings.Repeat("k", taskStateMaxListItemChars+10)}, + Decisions: []string{strings.Repeat("d", taskStateMaxListItemChars+10)}, + UserConstraints: []string{strings.Repeat("u", taskStateMaxListItemChars+10)}, + } + + for i := 0; i < taskStateMaxListItems+6; i++ { + input.Progress = append(input.Progress, strings.Repeat("x", taskStateMaxListItemChars-4)+buildIndexedSuffix(i)) + } + + clamped := ClampTaskStateBoundaries(input) + if len([]rune(clamped.Goal)) != taskStateMaxFieldChars { + t.Fatalf("expected goal to be clamped to %d runes, got %d", taskStateMaxFieldChars, len([]rune(clamped.Goal))) + } + if len([]rune(clamped.NextStep)) != taskStateMaxFieldChars { + t.Fatalf("expected next_step to be clamped to %d runes, got %d", taskStateMaxFieldChars, len([]rune(clamped.NextStep))) + } + if len(clamped.Progress) != taskStateMaxListItems { + t.Fatalf("expected progress length %d, got %d", taskStateMaxListItems, len(clamped.Progress)) + } + if len([]rune(clamped.Progress[0])) != taskStateMaxListItemChars { + t.Fatalf( + "expected progress item to be clamped to %d runes, got %d", + taskStateMaxListItemChars, + len([]rune(clamped.Progress[0])), + ) + } + if len([]rune(clamped.OpenItems[0])) != taskStateMaxListItemChars { + t.Fatalf("expected open item clamped to %d runes, got %d", taskStateMaxListItemChars, len([]rune(clamped.OpenItems[0]))) + } +} + +func TestClampTaskStateBoundariesKeepsZeroValueListsNil(t *testing.T) { + t.Parallel() + + clamped := ClampTaskStateBoundaries(TaskState{}) + if clamped.Progress != nil || clamped.OpenItems != nil || clamped.Blockers != nil { + t.Fatalf("expected empty list fields to stay nil, got %+v", clamped) + } +} + +func TestTruncateRunesHandlesBoundaryConditions(t *testing.T) { + t.Parallel() + + if got := truncateRunes("abc", 0); got != "" { + t.Fatalf("expected zero limit to return empty string, got %q", got) + } + if got := truncateRunes("", 10); got != "" { + t.Fatalf("expected empty input to stay empty, got %q", got) + } + if got := truncateRunes("你好世界", 2); got != "你好" { + t.Fatalf("expected unicode-safe truncate result %q, got %q", "你好", got) + } +}