Skip to content

Commit 5cc85f7

Browse files
committed
test: session compaction — observation mask and arg truncation
Cover createObservationMask() which generates the replacement text when old tool outputs are pruned during session compaction. Tests verify format correctness, UTF-8 byte counting, arg truncation with surrogate pair safety, unserializable input handling, and fingerprint capping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> https://claude.ai/code/session_01SHDrUNHjUpTwPvcjQcJ4ug
1 parent 9ba2114 commit 5cc85f7

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { SessionCompaction } from "../../src/session/compaction"
3+
import type { MessageV2 } from "../../src/session/message-v2"
4+
5+
// ─── Helpers ────────────────────────────────────────────────────────────────
6+
7+
function makeCompletedPart(overrides: {
8+
tool?: string
9+
input?: Record<string, any>
10+
output?: string
11+
}): MessageV2.ToolPart {
12+
return {
13+
id: "part-1",
14+
sessionID: "session-1",
15+
messageID: "msg-1",
16+
type: "tool",
17+
callID: "call-1",
18+
tool: overrides.tool ?? "read",
19+
state: {
20+
status: "completed",
21+
input: overrides.input ?? {},
22+
output: overrides.output ?? "",
23+
title: "test",
24+
metadata: {},
25+
time: { start: 1000, end: 2000 },
26+
},
27+
} as MessageV2.ToolPart
28+
}
29+
30+
function makePendingPart(overrides?: { tool?: string }): MessageV2.ToolPart {
31+
return {
32+
id: "part-1",
33+
sessionID: "session-1",
34+
messageID: "msg-1",
35+
type: "tool",
36+
callID: "call-1",
37+
tool: overrides?.tool ?? "bash",
38+
state: {
39+
status: "pending",
40+
input: { command: "ls -la" },
41+
raw: '{"command":"ls -la"}',
42+
},
43+
} as MessageV2.ToolPart
44+
}
45+
46+
// ─── createObservationMask: completed tool parts ────────────────────────────
47+
48+
describe("SessionCompaction.createObservationMask", () => {
49+
test("includes tool name, args, line count, byte size, and fingerprint for completed part", () => {
50+
const part = makeCompletedPart({
51+
tool: "bash",
52+
input: { command: "git status" },
53+
output: "On branch main\nnothing to commit, working tree clean\n",
54+
})
55+
const mask = SessionCompaction.createObservationMask(part)
56+
57+
expect(mask).toContain("[Tool output cleared")
58+
expect(mask).toContain("bash(")
59+
expect(mask).toContain('command: "git status"')
60+
expect(mask).toContain("3 lines")
61+
expect(mask).toContain("— \"On branch main\"")
62+
// Byte size should be present
63+
expect(mask).toMatch(/\d+ B/)
64+
})
65+
66+
test("omits fingerprint when output is empty", () => {
67+
const part = makeCompletedPart({ tool: "read", output: "" })
68+
const mask = SessionCompaction.createObservationMask(part)
69+
70+
expect(mask).toContain("read()")
71+
expect(mask).toContain("1 lines")
72+
expect(mask).toContain("0 B")
73+
// No fingerprint: the mask should end with the byte size then ] (no trailing — "...")
74+
expect(mask).not.toContain('— "')
75+
expect(mask).toMatch(/0 B\]$/)
76+
})
77+
78+
test("shows empty args for pending status (falls through to {} path)", () => {
79+
const part = makePendingPart({ tool: "bash" })
80+
const mask = SessionCompaction.createObservationMask(part)
81+
82+
// Pending status → output is "" (since only completed reads output)
83+
// Pending status → args from {} (not from input)
84+
expect(mask).toContain("bash()")
85+
expect(mask).toContain("1 lines")
86+
expect(mask).toContain("0 B")
87+
})
88+
89+
test("handles completed part with multi-line output", () => {
90+
const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`)
91+
const output = lines.join("\n")
92+
const part = makeCompletedPart({ tool: "grep", output })
93+
const mask = SessionCompaction.createObservationMask(part)
94+
95+
expect(mask).toContain("100 lines")
96+
expect(mask).toContain("— \"line 1\"")
97+
})
98+
99+
test("truncates long args with ellipsis", () => {
100+
const longValue = "x".repeat(200)
101+
const part = makeCompletedPart({
102+
tool: "write",
103+
input: { file_path: "/some/file.ts", content: longValue },
104+
output: "ok",
105+
})
106+
const mask = SessionCompaction.createObservationMask(part)
107+
108+
// Args should be truncated (maxLen=80) and end with "…"
109+
expect(mask).toContain("write(")
110+
expect(mask).toContain("…")
111+
// The full 200-char value should NOT appear
112+
expect(mask).not.toContain(longValue)
113+
})
114+
115+
test("handles unserializable input gracefully", () => {
116+
// Create a circular reference that JSON.stringify can't handle
117+
const circular: Record<string, any> = { key: "value" }
118+
circular.self = circular
119+
120+
const part = makeCompletedPart({
121+
tool: "bash",
122+
input: circular,
123+
output: "result",
124+
})
125+
const mask = SessionCompaction.createObservationMask(part)
126+
127+
expect(mask).toContain("bash([unserializable])")
128+
})
129+
130+
test("formats byte size in KB for larger outputs", () => {
131+
// 2048 bytes → should display as "2.0 KB"
132+
const output = "a".repeat(2048)
133+
const part = makeCompletedPart({ tool: "read", output })
134+
const mask = SessionCompaction.createObservationMask(part)
135+
136+
expect(mask).toContain("2.0 KB")
137+
})
138+
139+
test("formats byte size in MB for very large outputs", () => {
140+
// 1.5 MB output
141+
const output = "b".repeat(1024 * 1024 + 512 * 1024)
142+
const part = makeCompletedPart({ tool: "read", output })
143+
const mask = SessionCompaction.createObservationMask(part)
144+
145+
expect(mask).toContain("1.5 MB")
146+
})
147+
148+
test("correctly counts bytes for multi-byte UTF-8 characters", () => {
149+
// Each CJK character is 3 bytes in UTF-8
150+
const output = "你好世界" // 4 chars × 3 bytes = 12 bytes
151+
const part = makeCompletedPart({ tool: "read", output })
152+
const mask = SessionCompaction.createObservationMask(part)
153+
154+
expect(mask).toContain("12 B")
155+
expect(mask).toContain("— \"你好世界\"")
156+
})
157+
158+
test("fingerprint is capped at 80 characters", () => {
159+
const longFirstLine = "z".repeat(200)
160+
const part = makeCompletedPart({ tool: "bash", output: longFirstLine })
161+
const mask = SessionCompaction.createObservationMask(part)
162+
163+
// The fingerprint should contain the first 80 chars, not all 200
164+
const fingerprint80 = "z".repeat(80)
165+
expect(mask).toContain(`— "${fingerprint80}"`)
166+
expect(mask).not.toContain("z".repeat(81))
167+
})
168+
})

0 commit comments

Comments
 (0)