Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions internal/runtime/compact_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/runtime/compact_generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
48 changes: 39 additions & 9 deletions internal/session/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
105 changes: 105 additions & 0 deletions internal/session/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions internal/session/task_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import (
const (
// CurrentSchemaVersion 表示当前会话持久化结构的唯一合法版本。
CurrentSchemaVersion = 1

// taskStateMaxFieldChars 限制 TaskState 单值字段的最大字符数,避免异常大文本污染持久化与后续 prompt。
taskStateMaxFieldChars = 2000
// taskStateMaxListItems 限制 TaskState 列表字段的最大条目数,避免模型输出超大数组导致上下文膨胀。
taskStateMaxListItems = 32
// taskStateMaxListItemChars 限制 TaskState 列表单条目的最大字符数,避免单项异常放大。
taskStateMaxListItemChars = 400
)

// TaskState 表示会话级、可持久化的任务续航状态。
Expand Down Expand Up @@ -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 {
Expand All @@ -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])
}
69 changes: 69 additions & 0 deletions internal/session/task_state_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading