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
28 changes: 17 additions & 11 deletions internal/context/source_task_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ func (taskStateSource) Sections(ctx context.Context, input BuildInput) ([]prompt

// renderTaskStateSection 把任务状态转成稳定顺序的文本段,供模型恢复长期任务上下文。
func renderTaskStateSection(state agentsession.TaskState) promptSection {
lines := make([]string, 0, 8)
lines = append(lines, fmt.Sprintf("- goal: %s", promptTaskStateValue(state.Goal)))
lines = append(lines, fmt.Sprintf("- progress: %s", promptTaskStateListValue(state.Progress)))
lines = append(lines, fmt.Sprintf("- open_items: %s", promptTaskStateListValue(state.OpenItems)))
lines = append(lines, fmt.Sprintf("- next_step: %s", promptTaskStateValue(state.NextStep)))
lines = append(lines, fmt.Sprintf("- blockers: %s", promptTaskStateListValue(state.Blockers)))
lines = append(lines, fmt.Sprintf("- key_artifacts: %s", promptTaskStateListValue(state.KeyArtifacts)))
lines = append(lines, fmt.Sprintf("- decisions: %s", promptTaskStateListValue(state.Decisions)))
lines = append(lines, fmt.Sprintf("- user_constraints: %s", promptTaskStateListValue(state.UserConstraints)))
lines := []string{
fmt.Sprintf("- goal: %s", promptTaskStateValue(state.Goal)),
fmt.Sprintf("- progress: %s", promptTaskStateListValue(state.Progress)),
fmt.Sprintf("- open_items: %s", promptTaskStateListValue(state.OpenItems)),
fmt.Sprintf("- next_step: %s", promptTaskStateValue(state.NextStep)),
fmt.Sprintf("- blockers: %s", promptTaskStateListValue(state.Blockers)),
fmt.Sprintf("- key_artifacts: %s", promptTaskStateListValue(state.KeyArtifacts)),
fmt.Sprintf("- decisions: %s", promptTaskStateListValue(state.Decisions)),
fmt.Sprintf("- user_constraints: %s", promptTaskStateListValue(state.UserConstraints)),
}

return promptSection{
Title: "Task State",
Expand All @@ -50,7 +51,7 @@ func promptTaskStateValue(value string) string {
if value == "" {
return "none"
}
return value
return escapePromptTaskStateLineBreaks(value)
}

// promptTaskStateListValue 统一渲染任务状态中的列表字段。
Expand All @@ -65,7 +66,7 @@ func promptTaskStateListValue(values []string) string {
if value == "" {
continue
}
sanitized = append(sanitized, value)
sanitized = append(sanitized, escapePromptTaskStateLineBreaks(value))
}
if len(sanitized) == 0 {
return "none"
Expand Down Expand Up @@ -94,3 +95,8 @@ func sanitizePromptTaskStateText(value string) string {
}
return strings.Join(cleaned, "\n")
}

// escapePromptTaskStateLineBreaks 在渲染到单行键值结构前转义换行,避免多行内容破坏 prompt 结构。
func escapePromptTaskStateLineBreaks(value string) string {
return strings.ReplaceAll(value, "\n", `\n`)
}
28 changes: 22 additions & 6 deletions internal/context/source_task_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ func TestRenderTaskStateSectionSanitizesValues(t *testing.T) {
})

want := strings.Join([]string{
"- goal: finish\nmigration",
"- progress: first\nitem | second item",
"- open_items: review\ncomment",
"- next_step: run tests\nnow",
"- goal: finish\\nmigration",
"- progress: first\\nitem | second item",
"- open_items: review\\ncomment",
"- next_step: run tests\\nnow",
"- blockers: none needed",
"- key_artifacts: internal/context/source_task_state.go",
"- decisions: keep\nsingle-line format",
"- user_constraints: do-not migrate\nold-data",
"- decisions: keep\\nsingle-line format",
"- user_constraints: do-not migrate\\nold-data",
}, "\n")

if section.Title != "Task State" {
Expand Down Expand Up @@ -60,3 +60,19 @@ func TestRenderTaskStateSectionUsesNonePlaceholdersAndStableOrder(t *testing.T)
t.Fatalf("unexpected section content:\nwant:\n%s\n\ngot:\n%s", want, section.Content)
}
}

func TestRenderTaskStateSectionEscapesPromptLineBreakInjection(t *testing.T) {
t.Parallel()

section := renderTaskStateSection(agentsession.TaskState{
Goal: `safe
- injected: true`,
})

if strings.Contains(section.Content, "\n- injected: true") {
t.Fatalf("expected injected line to be escaped, got:\n%s", section.Content)
}
if !strings.Contains(section.Content, `safe\n- injected: true`) {
t.Fatalf("expected escaped newline marker, got:\n%s", section.Content)
}
}
43 changes: 21 additions & 22 deletions internal/runtime/compact_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"neo-code/internal/provider"
"neo-code/internal/provider/streaming"
providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
)

type compactSummaryGenerator struct {
Expand Down Expand Up @@ -113,17 +114,20 @@ func parseCompactSummaryOutput(content string) (contextcompact.SummaryOutput, er
return contextcompact.SummaryOutput{}, err
}

task := raw.TaskState
output := contextcompact.SummaryOutput{
DisplaySummary: strings.TrimSpace(raw.DisplaySummary),
TaskState: agentsession.TaskState{
Goal: task.Goal,
Progress: coerceStringArray(task.Progress),
OpenItems: coerceStringArray(task.OpenItems),
NextStep: task.NextStep,
Blockers: coerceStringArray(task.Blockers),
KeyArtifacts: coerceStringArray(task.KeyArtifacts),
Decisions: coerceStringArray(task.Decisions),
UserConstraints: coerceStringArray(task.UserConstraints),
},
}
output.TaskState.Goal = raw.TaskState.Goal
output.TaskState.Progress = coerceStringArray(raw.TaskState.Progress)
output.TaskState.OpenItems = coerceStringArray(raw.TaskState.OpenItems)
output.TaskState.NextStep = raw.TaskState.NextStep
output.TaskState.Blockers = coerceStringArray(raw.TaskState.Blockers)
output.TaskState.KeyArtifacts = coerceStringArray(raw.TaskState.KeyArtifacts)
output.TaskState.Decisions = coerceStringArray(raw.TaskState.Decisions)
output.TaskState.UserConstraints = coerceStringArray(raw.TaskState.UserConstraints)

if output.DisplaySummary == "" {
return contextcompact.SummaryOutput{}, errors.New("runtime: compact summary response is empty")
Expand Down Expand Up @@ -159,20 +163,17 @@ func coerceStringArray(raw json.RawMessage) []string {
if err := json.Unmarshal(raw, &arr); err == nil {
return arr
}
return nil
case '"':
var s string
if err := json.Unmarshal(raw, &s); err == nil {
s = strings.TrimSpace(s)
if s != "" {
return []string{s}
trimmed := strings.TrimSpace(s)
if trimmed != "" {
return []string{trimmed}
}
}
return nil
default:
// null、数字、布尔、对象等均返回 nil
return nil
}
// null、数字、布尔、对象等均返回 nil
return nil
}

// extractJSONObject 从模型响应中提取首个满足 compact 协议的 JSON 对象,容忍前后噪音。
Expand All @@ -185,12 +186,10 @@ func extractJSONObject(text string) (string, error) {
for {
candidate, err := extractJSONObjectCandidate(text, start)
if err == nil {
// 验证候选对象可被容忍解析器接受
var probe tolerantSummaryResponse
if unmarshalErr := json.Unmarshal([]byte(candidate), &probe); unmarshalErr == nil {
if strings.TrimSpace(probe.DisplaySummary) != "" {
return candidate, nil
}
// 与最终解析保持一致:候选对象必须通过严格解码且包含非空 display_summary。
if probe, decodeErr := decodeCompactSummaryResponse(candidate); decodeErr == nil &&
strings.TrimSpace(probe.DisplaySummary) != "" {
return candidate, nil
}
}

Expand Down
21 changes: 18 additions & 3 deletions internal/runtime/compact_generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import (
)

func validCompactSummaryJSON() string {
return strings.Join([]string{
`{"task_state":{"goal":"Finish task state refactor","progress":["Persisted task_state in session"],"open_items":["Update runtime tests"],"next_step":"Continue from retained context","blockers":[],"key_artifacts":["internal/runtime/compact_generator.go"],"decisions":["Do not keep old summary-only protocol"],"user_constraints":["No backward compatibility"]},"display_summary":"[compact_summary]\ndone:\n- Persisted durable task state.\n\nin_progress:\n- Continue from the retained recent window.\n\ndecisions:\n- Do not keep the old summary-only protocol.\n\ncode_changes:\n- Updated compact summary generation behavior.\n\nconstraints:\n- Preserve only the minimum information needed to continue the work."}`,
}, "")
return `{"task_state":{"goal":"Finish task state refactor","progress":["Persisted task_state in session"],"open_items":["Update runtime tests"],"next_step":"Continue from retained context","blockers":[],"key_artifacts":["internal/runtime/compact_generator.go"],"decisions":["Do not keep old summary-only protocol"],"user_constraints":["No backward compatibility"]},"display_summary":"[compact_summary]\ndone:\n- Persisted durable task state.\n\nin_progress:\n- Continue from the retained recent window.\n\ndecisions:\n- Do not keep the old summary-only protocol.\n\ncode_changes:\n- Updated compact summary generation behavior.\n\nconstraints:\n- Preserve only the minimum information needed to continue the work."}`
}

func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) {
Expand Down Expand Up @@ -388,6 +386,23 @@ func TestParseCompactSummaryOutputSkipsNonCompactJSONPreface(t *testing.T) {
}
}

func TestParseCompactSummaryOutputSkipsStrictlyInvalidCandidateAndUsesNext(t *testing.T) {
t.Parallel()

content := strings.Join([]string{
`noise {"task_state":{"goal":"bad","progress":[],"open_items":[],"next_step":"","blockers":[],"key_artifacts":[],"decisions":[],"user_constraints":[],"unexpected":"x"},"display_summary":"[compact_summary]\ninvalid"}`,
`{"task_state":{"goal":"good","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 skip invalid strict candidate, got %v", err)
}
if output.TaskState.Goal != "good" {
t.Fatalf("expected second valid candidate, got %+v", output.TaskState)
}
}

func TestParseCompactSummaryOutputRejectsUnknownTopLevelField(t *testing.T) {
t.Parallel()

Expand Down
Loading