|
| 1 | +/** |
| 2 | + * Adversarial + coverage tests for v0.8.10. |
| 3 | + * |
| 4 | + * Shipping changes since v0.8.9: |
| 5 | + * - #950 (fixes #949): move the volatile "Today's date" out of the |
| 6 | + * cache-controlled SYSTEM prefix and carry it on the trailing user message |
| 7 | + * instead. Two coupled halves: |
| 8 | + * (a) SystemPrompt.environment() no longer emits the date (system prefix |
| 9 | + * stays byte-identical across midnight → the expensive system-prefix |
| 10 | + * cache is not invalidated). |
| 11 | + * (b) SessionPrompt appends a `synthetic:true` text part |
| 12 | + * `\n\n${SystemPrompt.currentDate()}` to the LAST user message so the |
| 13 | + * model still receives today's date every turn. |
| 14 | + * The pre-existing test only covered half (a). The review (Data Engineer + |
| 15 | + * Tech Lead) flagged that half (b) — the part that actually matters for the |
| 16 | + * agent knowing the date — was untested. These tests close that gap and |
| 17 | + * probe the contract under hostile inputs. |
| 18 | + * - #959 (fixes #958): Windows installer Pester harness env injection made |
| 19 | + * deterministic. Test-harness only; a static regression guard here prevents |
| 20 | + * silently reintroducing the Process-scope-inheritance bug. |
| 21 | + * |
| 22 | + * Determinism: clock frozen with setSystemTime where the date is asserted; no |
| 23 | + * network, no shared state. No mock.module(). |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, expect, setSystemTime, test } from "bun:test" |
| 27 | +import fs from "fs" |
| 28 | +import path from "path" |
| 29 | +import { SystemPrompt } from "../../src/session/system" |
| 30 | +import { MessageV2 } from "../../src/session/message-v2" |
| 31 | +import type { Provider } from "../../src/provider/provider" |
| 32 | +import { ModelID, ProviderID } from "../../src/provider/schema" |
| 33 | +import { SessionID, MessageID, PartID } from "../../src/session/schema" |
| 34 | + |
| 35 | +const sessionID = SessionID.make("ses_v0_8_10") |
| 36 | +const providerID = ProviderID.make("test") |
| 37 | + |
| 38 | +const model: Provider.Model = { |
| 39 | + id: ModelID.make("test-model"), |
| 40 | + providerID, |
| 41 | + api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" }, |
| 42 | + name: "Test Model", |
| 43 | + capabilities: { |
| 44 | + temperature: true, |
| 45 | + reasoning: false, |
| 46 | + attachment: false, |
| 47 | + toolcall: true, |
| 48 | + input: { text: true, audio: false, image: false, video: false, pdf: false }, |
| 49 | + output: { text: true, audio: false, image: false, video: false, pdf: false }, |
| 50 | + interleaved: false, |
| 51 | + }, |
| 52 | + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, |
| 53 | + limit: { context: 0, input: 0, output: 0 }, |
| 54 | + status: "active", |
| 55 | + options: {}, |
| 56 | + headers: {}, |
| 57 | + release_date: "2026-01-01", |
| 58 | +} as Provider.Model |
| 59 | + |
| 60 | +function userInfo(id: string): MessageV2.User { |
| 61 | + return { |
| 62 | + id, |
| 63 | + sessionID, |
| 64 | + role: "user", |
| 65 | + time: { created: 0 }, |
| 66 | + agent: "user", |
| 67 | + model: { providerID, modelID: ModelID.make("test") }, |
| 68 | + tools: {}, |
| 69 | + mode: "", |
| 70 | + } as unknown as MessageV2.User |
| 71 | +} |
| 72 | + |
| 73 | +function basePart(messageID: string, id: string) { |
| 74 | + return { id: PartID.make(id), sessionID, messageID: MessageID.make(messageID) } |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Faithful replica of the prompt.ts append (the inline block at |
| 79 | + * session/prompt.ts:987-1003 is not exported). Mutates a copy and returns it so |
| 80 | + * tests can assert the observable contract via toModelMessages. The SHAPE here |
| 81 | + * must mirror the real code: a trailing `synthetic:true` text part whose text is |
| 82 | + * `\n\n${SystemPrompt.currentDate()}`, attached to the LAST user message only. |
| 83 | + */ |
| 84 | +function appendDateToLastUserMessage(msgs: MessageV2.WithParts[]): MessageV2.WithParts[] { |
| 85 | + const copy = msgs.map((m) => ({ info: m.info, parts: [...m.parts] })) |
| 86 | + const lastUser = [...copy].reverse().find((m) => m.info.role === "user") |
| 87 | + if (lastUser) { |
| 88 | + lastUser.parts = [ |
| 89 | + ...lastUser.parts, |
| 90 | + { |
| 91 | + ...basePart(lastUser.info.id, `date-${lastUser.info.id}`), |
| 92 | + type: "text", |
| 93 | + text: `\n\n${SystemPrompt.currentDate()}`, |
| 94 | + synthetic: true, |
| 95 | + } as MessageV2.Part, |
| 96 | + ] |
| 97 | + } |
| 98 | + return copy |
| 99 | +} |
| 100 | + |
| 101 | +// --------------------------------------------------------------------------- |
| 102 | +// #950 (a) — SystemPrompt.currentDate() is the single date source |
| 103 | +// --------------------------------------------------------------------------- |
| 104 | +describe("v0.8.10 #950: currentDate() generator", () => { |
| 105 | + test("renders today's date deterministically under a frozen clock", () => { |
| 106 | + setSystemTime(new Date("2026-06-22T12:00:00.000Z")) |
| 107 | + try { |
| 108 | + const today = new Date().toDateString() |
| 109 | + expect(SystemPrompt.currentDate()).toBe(`Today's date is ${today}.`) |
| 110 | + expect(SystemPrompt.currentDate()).toContain(today) |
| 111 | + } finally { |
| 112 | + setSystemTime() |
| 113 | + } |
| 114 | + }) |
| 115 | + |
| 116 | + test("re-renders the NEW date after the clock crosses midnight", () => { |
| 117 | + setSystemTime(new Date("2026-06-22T23:59:59.000Z")) |
| 118 | + const before = SystemPrompt.currentDate() |
| 119 | + setSystemTime(new Date("2026-06-23T00:00:01.000Z")) |
| 120 | + const after = SystemPrompt.currentDate() |
| 121 | + setSystemTime() |
| 122 | + // The whole point of #949: the date is regenerated each turn, so a session |
| 123 | + // that crosses midnight reflects the new day rather than a stale cached one. |
| 124 | + expect(before).not.toBe(after) |
| 125 | + expect(after).toContain("2026") |
| 126 | + }) |
| 127 | + |
| 128 | + test("is a single-line, brace-free fragment (cannot perturb prompt structure)", () => { |
| 129 | + const out = SystemPrompt.currentDate() |
| 130 | + expect(out.includes("\n")).toBe(false) |
| 131 | + expect(out).not.toMatch(/[{}<>]/) |
| 132 | + }) |
| 133 | +}) |
| 134 | + |
| 135 | +// --------------------------------------------------------------------------- |
| 136 | +// #950 (b) — the date reaches the model via the trailing user message |
| 137 | +// (the half that the pre-existing system.test.ts did NOT cover) |
| 138 | +// --------------------------------------------------------------------------- |
| 139 | +describe("v0.8.10 #950: date carried to the model on the last user message", () => { |
| 140 | + test("appended synthetic date survives toModelMessages and reaches the model", async () => { |
| 141 | + setSystemTime(new Date("2026-06-22T12:00:00.000Z")) |
| 142 | + try { |
| 143 | + const today = new Date().toDateString() |
| 144 | + const input: MessageV2.WithParts[] = [ |
| 145 | + { |
| 146 | + info: userInfo("m1"), |
| 147 | + parts: [{ ...basePart("m1", "p1"), type: "text", text: "hello" }] as MessageV2.Part[], |
| 148 | + }, |
| 149 | + ] |
| 150 | + const withDate = appendDateToLastUserMessage(input) |
| 151 | + const out = await MessageV2.toModelMessages(withDate, model) |
| 152 | + const text = JSON.stringify(out) |
| 153 | + expect(text).toContain(today) |
| 154 | + expect(text).toContain("hello") |
| 155 | + // date is last so it does not displace the user's real text |
| 156 | + expect(text.indexOf("hello")).toBeLessThan(text.indexOf(today)) |
| 157 | + } finally { |
| 158 | + setSystemTime() |
| 159 | + } |
| 160 | + }) |
| 161 | + |
| 162 | + test("only the LAST user message gets the date; earlier user turns stay clean", () => { |
| 163 | + setSystemTime(new Date("2026-06-22T12:00:00.000Z")) |
| 164 | + try { |
| 165 | + const today = new Date().toDateString() |
| 166 | + const input: MessageV2.WithParts[] = [ |
| 167 | + { info: userInfo("u1"), parts: [{ ...basePart("u1", "p1"), type: "text", text: "first" }] as MessageV2.Part[] }, |
| 168 | + { info: userInfo("u2"), parts: [{ ...basePart("u2", "p1"), type: "text", text: "second" }] as MessageV2.Part[] }, |
| 169 | + ] |
| 170 | + const withDate = appendDateToLastUserMessage(input) |
| 171 | + const first = JSON.stringify(withDate[0].parts) |
| 172 | + const second = JSON.stringify(withDate[1].parts) |
| 173 | + expect(first).not.toContain(today) |
| 174 | + expect(second).toContain(today) |
| 175 | + // exactly one synthetic date part total |
| 176 | + const dateParts = withDate.flatMap((m) => m.parts).filter((p: any) => p.type === "text" && p.text?.includes(today)) |
| 177 | + expect(dateParts.length).toBe(1) |
| 178 | + } finally { |
| 179 | + setSystemTime() |
| 180 | + } |
| 181 | + }) |
| 182 | + |
| 183 | + test("does NOT accumulate across turns (fresh reload each turn → one date)", () => { |
| 184 | + setSystemTime(new Date("2026-06-22T12:00:00.000Z")) |
| 185 | + try { |
| 186 | + const today = new Date().toDateString() |
| 187 | + // Each turn the loop re-fetches msgs fresh from the store (prompt.ts:440); |
| 188 | + // the synthetic part is never persisted. Model a 3-turn session by rebuilding |
| 189 | + // the base array each turn and re-applying the append. |
| 190 | + const base = (): MessageV2.WithParts[] => [ |
| 191 | + { info: userInfo("u1"), parts: [{ ...basePart("u1", "p1"), type: "text", text: "turn" }] as MessageV2.Part[] }, |
| 192 | + ] |
| 193 | + for (let turn = 0; turn < 3; turn++) { |
| 194 | + const withDate = appendDateToLastUserMessage(base()) |
| 195 | + const dates = withDate |
| 196 | + .flatMap((m) => m.parts) |
| 197 | + .filter((p: any) => p.type === "text" && p.text?.includes(today)) |
| 198 | + expect(dates.length).toBe(1) |
| 199 | + } |
| 200 | + } finally { |
| 201 | + setSystemTime() |
| 202 | + } |
| 203 | + }) |
| 204 | + |
| 205 | + test("date still reaches the model even when the user's real text was ignored", async () => { |
| 206 | + // Edge raised in review: a user turn whose only real part is `ignored` would |
| 207 | + // previously render empty; now it carries the date. The date must survive. |
| 208 | + setSystemTime(new Date("2026-06-22T12:00:00.000Z")) |
| 209 | + try { |
| 210 | + const today = new Date().toDateString() |
| 211 | + const input: MessageV2.WithParts[] = [ |
| 212 | + { |
| 213 | + info: userInfo("m1"), |
| 214 | + parts: [{ ...basePart("m1", "p1"), type: "text", text: "noise", ignored: true }] as MessageV2.Part[], |
| 215 | + }, |
| 216 | + ] |
| 217 | + const out = await MessageV2.toModelMessages(appendDateToLastUserMessage(input), model) |
| 218 | + expect(JSON.stringify(out)).toContain(today) |
| 219 | + } finally { |
| 220 | + setSystemTime() |
| 221 | + } |
| 222 | + }) |
| 223 | + |
| 224 | + test("no user message present → append is a no-op and nothing throws", () => { |
| 225 | + const input: MessageV2.WithParts[] = [] |
| 226 | + expect(() => appendDateToLastUserMessage(input)).not.toThrow() |
| 227 | + expect(appendDateToLastUserMessage(input)).toEqual([]) |
| 228 | + }) |
| 229 | +}) |
| 230 | + |
| 231 | +// --------------------------------------------------------------------------- |
| 232 | +// #959 — Windows installer Pester harness regression guard (static) |
| 233 | +// --------------------------------------------------------------------------- |
| 234 | +describe("v0.8.10 #959: Windows installer Pester harness stays deterministic", () => { |
| 235 | + const psPath = path.resolve(import.meta.dir, "../../../../test/windows/install.Tests.ps1") |
| 236 | + |
| 237 | + test("env injection happens inside the child session, not via host inheritance", () => { |
| 238 | + expect(fs.existsSync(psPath)).toBe(true) |
| 239 | + const src = fs.readFileSync(psPath, "utf8") |
| 240 | + // The fix sets the requested env vars in the child's own session (-Command |
| 241 | + // preamble with `$env:` / Remove-Item) rather than mutating the host's |
| 242 | + // Process-scope env and relying on `pwsh -File` inheritance — which the |
| 243 | + // updated windows-latest runner re-initialized for loader-managed vars. |
| 244 | + expect(src).toContain("-Command") |
| 245 | + expect(src).toMatch(/\$env:/) |
| 246 | + expect(src).toMatch(/Remove-Item -Path Env:/) |
| 247 | + // The old, broken inheritance pattern must not come back. |
| 248 | + expect(src).not.toMatch(/SetEnvironmentVariable/) |
| 249 | + }) |
| 250 | +}) |
0 commit comments