Skip to content

Commit da244bc

Browse files
fix(sessions): render resume context fields in key order (#1043)
* fix(sessions): render resume context fields in key order extractText walked a map[string]any directly and Go randomizes map iteration, so the same session put the same fields in a different order in every process: one run rendered a tool call as c1 read_file {"path":...}, the next as {"path":...} c1 read_file. That block is part of the user prompt, so the prompt itself changed run to run, which leaves a provider nothing stable to prefix-cache for it and makes two resumes of one session impossible to diff while debugging. Only the ordering is decided here. The summary short-circuit is untouched, a list keeps the order it was written in, and which fields survive at all stays the caller's projection. Pinned as exact rendered strings rather than as two renders agreeing: a same-process repeat passes whenever the random order happens to repeat, which for a small map is often. The raw-JSON case proved it while being written, matching on the first five attempts before diverging on the sixth. Closes #1020 * test(sessions): name the expected context block instead of the first render Comparing later renders against the first only asks whether the renderer is stable, and a renderer that is stably wrong passes it: any fixed field order satisfies it, including the unsorted one this change replaces. It also hid something. The events were tool calls, which promptContextEvents filters out of the resume context on this branch, so the block being compared was a single message line and nothing said so. The test now names the block it expects, on events that survive the filter, and both failures are visible.
1 parent e34c8d2 commit da244bc

2 files changed

Lines changed: 154 additions & 3 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package sessions
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// ONE SESSION HAS TO RENDER ONE PROMPT.
10+
//
11+
// extractText walked a map[string]any directly, and Go randomizes map
12+
// iteration, so the resume context put the same fields in a different order in
13+
// every process: `c1 read_file {"path":...}` one run, `{"path":...} c1
14+
// read_file` the next. That block is part of the user prompt, so the prompt
15+
// itself changed run to run, which leaves a provider nothing stable to
16+
// prefix-cache and makes two resumes of one session impossible to diff.
17+
//
18+
// PINNED AS AN EXACT STRING, NOT AS TWO RUNS AGREEING. A test that renders the
19+
// same payload twice in one process and compares the results passes whenever
20+
// the randomized order happens to repeat, which for a small map is often. The
21+
// only assertion that cannot pass by luck names the order it expects.
22+
func TestSummarizePayloadRendersFieldsInKeyOrder(t *testing.T) {
23+
payload := map[string]any{
24+
"id": "c1",
25+
"name": "read_file",
26+
"arguments": `{"path":"deploy/prod.env"}`,
27+
"status": "ok",
28+
}
29+
30+
// Keys sorted: arguments, id, name, status.
31+
const want = `{"path":"deploy/prod.env"} c1 read_file ok`
32+
for attempt := range 50 {
33+
if got := summarizePayload(payload); got != want {
34+
t.Fatalf("attempt %d rendered %q, want %q", attempt, got, want)
35+
}
36+
}
37+
}
38+
39+
// The same for a payload that arrives as raw JSON, which is the shape the store
40+
// hands back, since that decodes into a map and takes the same path.
41+
func TestSummarizePayloadRendersRawJSONInKeyOrder(t *testing.T) {
42+
raw, err := json.Marshal(map[string]any{
43+
"zulu": "last",
44+
"alpha": "first",
45+
"mike": "middle",
46+
})
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
51+
const want = "first middle last"
52+
for attempt := range 50 {
53+
if got := summarizePayload(json.RawMessage(raw)); got != want {
54+
t.Fatalf("attempt %d rendered %q, want %q", attempt, got, want)
55+
}
56+
}
57+
}
58+
59+
// The summary short-circuit is untouched: a payload carrying one still answers
60+
// with it alone, rather than with every field in key order.
61+
func TestSummarizePayloadStillPrefersASummary(t *testing.T) {
62+
payload := map[string]any{
63+
"alpha": "not this",
64+
"summary": "this one",
65+
"zulu": "nor this",
66+
}
67+
if got := summarizePayload(payload); got != "this one" {
68+
t.Fatalf("rendered %q, want the summary alone", got)
69+
}
70+
}
71+
72+
// And a list keeps the order it was written in, which is the caller's and not
73+
// this function's to sort.
74+
func TestSummarizePayloadKeepsListOrder(t *testing.T) {
75+
payload := map[string]any{"items": []any{"third", "first", "second"}}
76+
if got := summarizePayload(payload); got != "third first second" {
77+
t.Fatalf("rendered %q, want the list in its written order", got)
78+
}
79+
}
80+
81+
// THE WHOLE BLOCK IS STABLE, not just one payload. Rendered through the
82+
// prompt-context path a resume actually uses, so a future renderer that walks a
83+
// map of its own is caught here too.
84+
//
85+
// THE EXPECTED BLOCK IS WRITTEN OUT, NOT TAKEN FROM THE FIRST RENDER. Comparing
86+
// later renders against the first only asks whether the renderer is stable, and
87+
// a renderer that is stably wrong passes: any fixed field order satisfies it,
88+
// including the one this change replaces. It also hid something. The first
89+
// version of this test used tool events, which promptContextEvents filters out
90+
// of the resume context on this branch, so the block being compared was a
91+
// single line and nobody could tell. Naming the expected block makes both
92+
// failures visible.
93+
func TestResumeContextBlockIsIdenticalAcrossRenders(t *testing.T) {
94+
events := []Event{
95+
{Sequence: 1, Type: EventMessage, Payload: mustOrderPayload(t, map[string]any{"role": "user", "content": "rotate the deploy key"})},
96+
{Sequence: 2, Type: EventMessage, Payload: mustOrderPayload(t, map[string]any{"role": "assistant", "content": "reading the env file"})},
97+
{Sequence: 3, Type: EventMessage, Payload: mustOrderPayload(t, map[string]any{"role": "user", "content": "and the hooks"})},
98+
}
99+
100+
// Keys sorted, so content comes before role in every line.
101+
want := strings.Join([]string{
102+
"message: rotate the deploy key user",
103+
"message: reading the env file assistant",
104+
"message: and the hooks user",
105+
}, "\n")
106+
107+
for attempt := range 50 {
108+
if got := renderOrderContext(t, events); got != want {
109+
t.Fatalf("attempt %d rendered:\n%s\nwant:\n%s", attempt, got, want)
110+
}
111+
}
112+
}
113+
114+
func renderOrderContext(t *testing.T, events []Event) string {
115+
t.Helper()
116+
lines := []string{}
117+
for _, event := range promptContextEvents(events) {
118+
var decoded any
119+
if len(event.Payload) > 0 {
120+
if err := json.Unmarshal(event.Payload, &decoded); err != nil {
121+
t.Fatal(err)
122+
}
123+
}
124+
lines = append(lines, string(event.Type)+": "+summarizePayload(decoded))
125+
}
126+
return strings.Join(lines, "\n")
127+
}
128+
129+
func mustOrderPayload(t *testing.T, payload map[string]any) json.RawMessage {
130+
t.Helper()
131+
raw, err := json.Marshal(payload)
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
return raw
136+
}

‎internal/sessions/exec_session.go‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,24 @@ func extractText(value any) string {
554554
if summary, ok := typed["summary"].(string); ok && strings.TrimSpace(summary) != "" {
555555
return summary
556556
}
557-
parts := []string{}
558-
for _, item := range typed {
559-
if text := extractText(item); text != "" {
557+
// BY KEY, BECAUSE THIS TEXT BECOMES PART OF A PROMPT. Go randomizes map
558+
// iteration, so the same session rendered its resume context in a
559+
// different field order in every process: one run said
560+
// `c1 read_file {"path":...}` and the next `{"path":...} c1 read_file`.
561+
// The block goes into the user prompt, so the prompt itself changed run
562+
// to run, which gives a provider nothing stable to prefix-cache and
563+
// leaves two resumes of one session impossible to diff while debugging.
564+
//
565+
// Only the ordering is decided here. Which fields survive is the
566+
// caller's projection, and a slice keeps the order it was written in.
567+
keys := make([]string, 0, len(typed))
568+
for key := range typed {
569+
keys = append(keys, key)
570+
}
571+
sort.Strings(keys)
572+
parts := make([]string, 0, len(keys))
573+
for _, key := range keys {
574+
if text := extractText(typed[key]); text != "" {
560575
parts = append(parts, text)
561576
}
562577
}

0 commit comments

Comments
 (0)