Skip to content

Commit f52da2d

Browse files
committed
release: v0.8.6
1 parent ecc7001 commit f52da2d

2 files changed

Lines changed: 346 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.6] - 2026-06-08
9+
10+
### Added
11+
12+
- **Session traces now work in headless `serve` mode — your VS Code "Altimate Code" chat sessions show up in `/traces` just like terminal sessions.** Sessions driven over `altimate serve` (the server behind the VS Code chat panel) had no TUI worker observing the event stream, so trace files were never written and `/traces` was always empty after a chat. The per-session tracing logic is now a shared `TraceConsumer` wired into both the TUI worker and `serve`, so IDE chat sessions produce the same incremental `ses_<id>.json` files — full multi-turn waterfall, recorded prompt, tool-call log, cost, and timing. `serve` now registers `SIGINT`/`SIGTERM`/`beforeExit` handlers that drain and finalize in-flight traces on shutdown (so a serve trace ends as `completed`, not `running`, and the process exits with signal-conventional codes), finalizes a session as soon as it is deleted, and finalizes all open sessions concurrently so a busy server isn't cut off mid-write by a container shutdown grace period. (#886)
13+
814
## [0.8.5] - 2026-06-06
915

1016
### Fixed
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
/**
2+
* Adversarial tests for v0.8.6 — session traces in headless serve mode (#886)
3+
* and the release-review fixes (concurrent flush, session.deleted eviction,
4+
* reconnect resilience).
5+
*
6+
* Covers the FINAL shipping code, including the Step-5 release fixes:
7+
* - TraceConsumer.handleEvent must never throw on hostile/malformed input
8+
* - sessionID sanitization (no path traversal escaping the trace dir)
9+
* - no prototype pollution from attacker-controlled event objects
10+
* - type confusion (non-string text/title/status) is coerced, not fatal
11+
* - the parentID-only session resolution fallback (previously untested)
12+
* - flush() finalizes many sessions concurrently
13+
* - session.deleted with malformed payloads is a safe no-op
14+
* - subscribeTraceConsumer survives a throwing/empty event source, reconnects,
15+
* and stop() is bounded + idempotent
16+
*
17+
* Determinism: file-visibility assertions poll via waitFor (snapshot/endTrace
18+
* writes are async + debounced) — no fixed sleeps. No mock.module().
19+
*/
20+
21+
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
22+
import fs from "fs/promises"
23+
import path from "path"
24+
import os from "os"
25+
import {
26+
TraceConsumer,
27+
subscribeTraceConsumer,
28+
type TraceEventSource,
29+
} from "../../src/altimate/observability/trace-consumer"
30+
import { FileExporter, type TraceFile } from "../../src/altimate/observability/tracing"
31+
32+
let tmpDir: string
33+
34+
beforeEach(async () => {
35+
tmpDir = path.join(os.tmpdir(), `trace-adv-086-${Date.now()}-${Math.random().toString(36).slice(2)}`)
36+
await fs.mkdir(tmpDir, { recursive: true })
37+
})
38+
39+
afterEach(async () => {
40+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
41+
})
42+
43+
function makeConsumer() {
44+
return new TraceConsumer({ exporters: [new FileExporter(tmpDir)] })
45+
}
46+
47+
async function waitFor<T>(read: () => Promise<T>, predicate: (v: T) => boolean, timeoutMs = 5000): Promise<T> {
48+
const start = Date.now()
49+
for (;;) {
50+
try {
51+
const v = await read()
52+
if (predicate(v)) return v
53+
} catch {
54+
// not ready
55+
}
56+
if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition not met within timeout")
57+
await new Promise((r) => setTimeout(r, 10))
58+
}
59+
}
60+
61+
function userMsg(sessionID: string, id: string, text: string) {
62+
const now = Date.now()
63+
return [
64+
{ type: "message.updated", properties: { info: { id, sessionID, role: "user", time: { created: now } } } },
65+
{
66+
type: "message.part.updated",
67+
properties: { part: { sessionID, messageID: id, type: "text", text, time: { end: now } } },
68+
},
69+
]
70+
}
71+
72+
function toolEvent(sessionID: string, callID: string) {
73+
const now = Date.now()
74+
return {
75+
type: "message.part.updated",
76+
properties: {
77+
part: {
78+
sessionID,
79+
type: "tool",
80+
tool: "bash",
81+
callID,
82+
state: { status: "completed", input: { command: "ls" }, output: "ok", time: { start: now - 5, end: now } },
83+
},
84+
},
85+
}
86+
}
87+
88+
describe("v0.8.6 adversarial — TraceConsumer hostile input", () => {
89+
test("malformed / hostile events never throw", async () => {
90+
const consumer = makeConsumer()
91+
const hostile: unknown[] = [
92+
null,
93+
undefined,
94+
0,
95+
"",
96+
[],
97+
{ type: "message.updated", properties: { info: { sessionID: 123 } } }, // numeric sessionID
98+
{ type: "message.part.updated", properties: { part: { sessionID: {}, type: "text", text: "x" } } }, // object sessionID
99+
{ type: "message.part.updated", properties: { part: { sessionID: "s", type: "tool", state: null } } },
100+
{ type: "session.updated", properties: { info: { id: "s" } } }, // missing title
101+
{ type: "session.deleted", properties: {} }, // missing info
102+
{ type: "session.deleted", properties: { info: {} } }, // missing id
103+
{ type: "session.deleted", properties: { info: { id: "never-seen" } } }, // unknown session
104+
{ type: "totally.unknown.event", properties: { foo: "bar" } },
105+
]
106+
for (const e of hostile) {
107+
await expect(consumer.handleEvent(e)).resolves.toBeUndefined()
108+
}
109+
await consumer.flush()
110+
})
111+
112+
test("sessionID path traversal cannot escape the trace directory", async () => {
113+
const consumer = makeConsumer()
114+
const evil = "../../../../tmp/pwned-" + Math.random().toString(36).slice(2)
115+
await consumer.handleEvent({
116+
type: "message.updated",
117+
properties: { info: { id: "u1", sessionID: evil, role: "user", time: { created: Date.now() } } },
118+
})
119+
await consumer.handleEvent(toolEvent(evil, "c1"))
120+
await consumer.flush()
121+
122+
// No file may be written outside tmpDir. The id is sanitized (slashes/dots
123+
// replaced), so any file produced lives directly under tmpDir.
124+
const escaped = path.resolve("/tmp", path.basename(evil) + ".json")
125+
// The sanitized name replaces the leading ../ etc with underscores → it
126+
// must NOT exist at the traversal target.
127+
const traversed = path.resolve(tmpDir, evil + ".json")
128+
await expect(fs.access(traversed)).rejects.toBeDefined()
129+
await expect(fs.access(escaped)).rejects.toBeDefined()
130+
const files = await fs.readdir(tmpDir)
131+
for (const f of files) {
132+
expect(f.includes("/")).toBe(false)
133+
expect(f.includes("..")).toBe(false)
134+
}
135+
})
136+
137+
test("prototype-pollution payload does not pollute Object.prototype", async () => {
138+
const consumer = makeConsumer()
139+
const payload = JSON.parse('{"type":"message.part.updated","properties":{"part":{"sessionID":"ses_pp","type":"text","messageID":"m","text":"hi","__proto__":{"polluted":"yes"}}}}')
140+
await consumer.handleEvent(payload)
141+
await consumer.handleEvent({
142+
type: "message.part.updated",
143+
properties: {
144+
part: { sessionID: "ses_pp", type: "text", text: "hi", constructor: { prototype: { polluted2: true } } },
145+
},
146+
})
147+
await consumer.flush()
148+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
149+
expect(({} as Record<string, unknown>).polluted2).toBeUndefined()
150+
})
151+
152+
test("type-confused fields are coerced, not fatal", async () => {
153+
const consumer = makeConsumer()
154+
const sid = "ses_typeconf"
155+
// user message whose summary.title is an object, text part whose text is a number
156+
await consumer.handleEvent({
157+
type: "message.updated",
158+
properties: {
159+
info: { id: "u1", sessionID: sid, role: "user", summary: { title: { nested: 1 } }, time: { created: Date.now() } },
160+
},
161+
})
162+
await consumer.handleEvent({
163+
type: "message.part.updated",
164+
properties: { part: { sessionID: sid, messageID: "u1", type: "text", text: 42 } },
165+
})
166+
// session.updated with a numeric title
167+
await consumer.handleEvent({ type: "session.updated", properties: { info: { id: sid, title: 999 } } })
168+
await consumer.flush()
169+
const trace = await waitFor(
170+
() => fs.readFile(path.join(tmpDir, `${sid}.json`), "utf8").then((r) => JSON.parse(r) as TraceFile),
171+
() => true,
172+
)
173+
// It wrote a valid file with string-coerced title.
174+
expect(typeof trace.metadata.title).toBe("string")
175+
})
176+
177+
test("parentID-only assistant message resolves its session (fallback path)", async () => {
178+
const consumer = makeConsumer()
179+
const sid = "ses_parentid"
180+
// user message establishes the user-msg-id -> session mapping
181+
for (const e of userMsg(sid, "msg-user-1", "do it")) await consumer.handleEvent(e)
182+
// assistant message.updated WITHOUT sessionID, only parentID pointing at the user msg
183+
await consumer.handleEvent({
184+
type: "message.updated",
185+
properties: {
186+
info: {
187+
id: "msg-asst-1",
188+
parentID: "msg-user-1",
189+
role: "assistant",
190+
modelID: "gpt-4o",
191+
providerID: "openai",
192+
agent: "general",
193+
time: { created: Date.now() },
194+
},
195+
},
196+
})
197+
await consumer.flush()
198+
const trace = await waitFor(
199+
() => fs.readFile(path.join(tmpDir, `${sid}.json`), "utf8").then((r) => JSON.parse(r) as TraceFile),
200+
(t) => !!t.metadata.model,
201+
)
202+
// enrichFromAssistant ran via the parentID fallback → model populated.
203+
expect(trace.metadata.model).toBe("openai/gpt-4o")
204+
})
205+
206+
test("flush() finalizes many concurrent sessions (parallel finalization)", async () => {
207+
const consumer = makeConsumer()
208+
const ids = Array.from({ length: 12 }, (_, i) => `ses_par_${i}`)
209+
for (const sid of ids) {
210+
for (const e of userMsg(sid, `u-${sid}`, "hi")) await consumer.handleEvent(e)
211+
await consumer.handleEvent(toolEvent(sid, `c-${sid}`))
212+
}
213+
await consumer.flush()
214+
for (const sid of ids) {
215+
const trace = await waitFor(
216+
() => fs.readFile(path.join(tmpDir, `${sid}.json`), "utf8").then((r) => JSON.parse(r) as TraceFile),
217+
(t) => t.summary.status === "completed",
218+
)
219+
expect(trace.summary.status).toBe("completed")
220+
expect(trace.summary.totalToolCalls).toBe(1)
221+
}
222+
})
223+
224+
test("session.deleted evicts state; a later event re-creates from disk, not clobbering", async () => {
225+
const consumer = makeConsumer()
226+
const sid = "ses_del_recreate"
227+
for (const e of userMsg(sid, "u1", "first")) await consumer.handleEvent(e)
228+
await consumer.handleEvent(toolEvent(sid, "c1"))
229+
await consumer.handleEvent({ type: "session.deleted", properties: { info: { id: sid } } })
230+
const finalized = await waitFor(
231+
() => fs.readFile(path.join(tmpDir, `${sid}.json`), "utf8").then((r) => JSON.parse(r) as TraceFile),
232+
(t) => t.summary.status === "completed",
233+
)
234+
expect(finalized.summary.totalToolCalls).toBe(1)
235+
// A late event for the same (now-evicted) session must rehydrate the rich
236+
// on-disk trace, not overwrite it with an empty one.
237+
await consumer.handleEvent(toolEvent(sid, "c2"))
238+
await consumer.flush()
239+
const after = await waitFor(
240+
() => fs.readFile(path.join(tmpDir, `${sid}.json`), "utf8").then((r) => JSON.parse(r) as TraceFile),
241+
(t) => t.summary.totalToolCalls >= 2,
242+
)
243+
expect(after.summary.totalToolCalls).toBe(2)
244+
})
245+
})
246+
247+
describe("v0.8.6 adversarial — subscribeTraceConsumer resilience", () => {
248+
function source(events: unknown[], opts?: { throwAfter?: number; holdUntilAbort?: AbortSignal }): TraceEventSource {
249+
async function* gen() {
250+
let i = 0
251+
for (const e of events) {
252+
yield e
253+
i++
254+
if (opts?.throwAfter !== undefined && i >= opts.throwAfter) throw new Error("adversarial disconnect")
255+
}
256+
const sig = opts?.holdUntilAbort
257+
if (sig) {
258+
await new Promise<void>((resolve) => {
259+
if (sig.aborted) return resolve()
260+
sig.addEventListener("abort", () => resolve(), { once: true })
261+
})
262+
}
263+
}
264+
return { stream: gen() }
265+
}
266+
267+
test("survives repeated subscribe failures (undefined) then recovers and finalizes", async () => {
268+
const consumer = makeConsumer()
269+
let calls = 0
270+
const sub = subscribeTraceConsumer(
271+
{ directory: tmpDir },
272+
{
273+
consumer,
274+
subscribe: async (signal) => {
275+
calls++
276+
// First two connects fail outright (undefined → backoff+retry).
277+
if (calls <= 2) return undefined
278+
// Third delivers a session, then holds open.
279+
if (calls === 3) {
280+
return source([...userMsg("ses_recover", "u1", "hi"), toolEvent("ses_recover", "c1")], {
281+
holdUntilAbort: signal,
282+
})
283+
}
284+
return source([], { holdUntilAbort: signal })
285+
},
286+
},
287+
)
288+
const trace = await waitFor(
289+
() => fs.readFile(path.join(tmpDir, "ses_recover.json"), "utf8").then((r) => JSON.parse(r) as TraceFile),
290+
(t) => t.summary.totalToolCalls >= 1,
291+
)
292+
expect(trace.summary.totalToolCalls).toBe(1)
293+
expect(calls).toBeGreaterThanOrEqual(3)
294+
await sub.stop()
295+
})
296+
297+
test("a subscribe() that throws synchronously does not crash the loop", async () => {
298+
const consumer = makeConsumer()
299+
let calls = 0
300+
const sub = subscribeTraceConsumer(
301+
{ directory: tmpDir },
302+
{
303+
consumer,
304+
subscribe: async (signal) => {
305+
calls++
306+
if (calls === 1) throw new Error("subscribe blew up")
307+
return source([...userMsg("ses_throw", "u1", "hi"), toolEvent("ses_throw", "c1")], {
308+
holdUntilAbort: signal,
309+
})
310+
},
311+
},
312+
)
313+
const trace = await waitFor(
314+
() => fs.readFile(path.join(tmpDir, "ses_throw.json"), "utf8").then((r) => JSON.parse(r) as TraceFile),
315+
(t) => t.summary.totalToolCalls >= 1,
316+
)
317+
expect(trace.summary.totalToolCalls).toBe(1)
318+
await sub.stop()
319+
})
320+
321+
test("stop() is bounded and idempotent even if the stream never ends", async () => {
322+
const consumer = makeConsumer()
323+
const sub = subscribeTraceConsumer(
324+
{ directory: tmpDir },
325+
{
326+
consumer,
327+
subscribe: async (signal) => source([...userMsg("ses_stop", "u1", "hi")], { holdUntilAbort: signal }),
328+
},
329+
)
330+
await waitFor(
331+
() => fs.readFile(path.join(tmpDir, "ses_stop.json"), "utf8").then((r) => JSON.parse(r) as TraceFile),
332+
() => true,
333+
)
334+
// First stop drains + flushes; second stop must be a harmless no-op.
335+
await sub.stop()
336+
await sub.stop()
337+
const trace = JSON.parse(await fs.readFile(path.join(tmpDir, "ses_stop.json"), "utf8")) as TraceFile
338+
expect(trace.summary.status).toBe("completed")
339+
})
340+
})

0 commit comments

Comments
 (0)