diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a40232970..92e90c43b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed +- Fixed Hindsight retains to send offset-aware local timestamps instead of UTC `Z` strings so extraction prompts keep the user's local time-of-day context ([#2363](https://github.com/can1357/oh-my-pi/issues/2363)). - Fixed tool calls taller than the viewport reading as cut off while streaming (the head reappeared only once the result landed): the 15.11.6 stranded-preview fix marked every collapsed pending tool preview commit-unstable, which also blocked durable top-anchored streams — e.g. a task call's context/assignment markdown — from reaching native scrollback mid-run. Commit stability is now classified per renderer (`ToolRenderer.provisionalPendingPreview`): only the tail-window previews the result render re-anchors (edit/apply_patch streamed-diff tails, bash/ssh command caps, eval cells with interleaved outputs) stay provisional; every other pending preview commits its settled head mid-stream again - Fixed `omp bench` reporting "tokens 0, TPS 0.0" successes on repeated OpenRouter runs: pi-ai opts every OpenRouter request into response caching, so bench's byte-identical request replayed a cached generation with zeroed usage at edge speed. Bench now sends `X-OpenRouter-Cache: false` so every run measures a fresh generation - Fixed `omp bench` failing with HTTP 400 `{"detail":"Instructions are required"}` against `openai-codex` models: bench requests now carry a minimal default system prompt (same guard as eval's completion bridge) diff --git a/packages/coding-agent/src/hindsight/client.ts b/packages/coding-agent/src/hindsight/client.ts index 7f942aab0..853ae786d 100644 --- a/packages/coding-agent/src/hindsight/client.ts +++ b/packages/coding-agent/src/hindsight/client.ts @@ -546,7 +546,7 @@ interface BuiltMemoryItem { function buildMemoryItem(item: MemoryItemInput): BuiltMemoryItem { const out: BuiltMemoryItem = { content: item.content }; if (item.timestamp !== undefined) { - out.timestamp = item.timestamp instanceof Date ? item.timestamp.toISOString() : item.timestamp; + out.timestamp = item.timestamp instanceof Date ? formatDateWithLocalOffset(item.timestamp) : item.timestamp; } if (item.context !== undefined) out.context = item.context; if (item.metadata !== undefined) out.metadata = item.metadata; @@ -558,6 +558,31 @@ function buildMemoryItem(item: MemoryItemInput): BuiltMemoryItem { return out; } +function formatDateWithLocalOffset(date: Date): string { + const offsetMinutes = date.getTimezoneOffset(); + const offsetSign = offsetMinutes <= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + const offsetHours = Math.floor(absoluteOffset / 60); + const offsetRemainderMinutes = absoluteOffset % 60; + const milliseconds = date.getMilliseconds(); + const millisecondsPart = milliseconds === 0 ? "" : `.${pad3(milliseconds)}`; + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2( + date.getHours(), + )}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}${millisecondsPart}${offsetSign}${pad2( + offsetHours, + )}:${pad2(offsetRemainderMinutes)}`; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +function pad3(value: number): string { + if (value < 10) return `00${value}`; + if (value < 100) return `0${value}`; + return String(value); +} + function buildQueryString(query: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { diff --git a/packages/coding-agent/src/hindsight/state.ts b/packages/coding-agent/src/hindsight/state.ts index 9938e4c99..39d70f9c2 100644 --- a/packages/coding-agent/src/hindsight/state.ts +++ b/packages/coding-agent/src/hindsight/state.ts @@ -26,6 +26,7 @@ const RETAIN_FLUSH_INTERVAL_MS = 5_000; interface PendingRetainItem { content: string; context?: string; + timestamp: Date; } interface RecallOutcome { @@ -84,7 +85,7 @@ export class HindsightRetainQueue { if (this.#closed) { throw new Error("Hindsight retain queue is closed."); } - this.#items.push({ content, context }); + this.#items.push({ content, context, timestamp: new Date() }); if (this.#items.length >= RETAIN_FLUSH_BATCH_SIZE) { void this.flush(); @@ -154,6 +155,7 @@ export class HindsightRetainQueue { context: item.context ?? state.config.retainContext, metadata: { session_id: sessionId }, tags: state.retainTags, + timestamp: item.timestamp, })); await state.client.retainBatch(state.bankId, batch, { async: true }); if (state.config.debug) { @@ -281,6 +283,7 @@ export class HindsightSessionState { } async retainSession(messages: HindsightMessage[]): Promise { + const retainedAt = new Date(); const retainFullWindow = this.config.retainMode === "full-session"; let target: HindsightMessage[]; let documentId: string; @@ -291,7 +294,7 @@ export class HindsightSessionState { } else { const windowTurns = this.config.retainEveryNTurns + this.config.retainOverlapTurns; target = sliceLastTurnsByUserBoundary(messages, windowTurns); - documentId = `${this.sessionId}-${Date.now()}`; + documentId = `${this.sessionId}-${retainedAt.getTime()}`; } const { transcript } = prepareRetentionTranscript(target, true); @@ -303,6 +306,7 @@ export class HindsightSessionState { context: this.config.retainContext, metadata: { session_id: this.sessionId }, tags: this.retainTags, + timestamp: retainedAt, async: true, }); } diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index e479a39a1..969f06fa6 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -174,6 +174,7 @@ describe("hindsightBackend.start", () => { session.emit({ type: "agent_end", messages: [] }); await Bun.sleep(0); expect(retainSpy).toHaveBeenCalledTimes(1); + expect(retainSpy.mock.calls[0]?.[2]?.timestamp).toBeInstanceOf(Date); }); it("aliases parent state on subagent runs (taskDepth > 0) so tools share the parent bank", async () => { @@ -876,6 +877,7 @@ describe("hindsightBackend retain queue flush on session teardown", () => { expect(bankId).toBe("omp"); expect(items).toHaveLength(1); expect(items[0].content).toBe("durable fact"); + expect(items[0].timestamp).toBeInstanceOf(Date); }); // Companion contract test: documents the failure mode the dispose-order diff --git a/packages/coding-agent/test/hindsight-client.test.ts b/packages/coding-agent/test/hindsight-client.test.ts new file mode 100644 index 000000000..64c2bc142 --- /dev/null +++ b/packages/coding-agent/test/hindsight-client.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; + +function captureRequestBodies(): string[] { + const bodies: string[] = []; + const fetchMock: typeof globalThis.fetch = Object.assign( + async (_input: string | URL | Request, init?: RequestInit | BunFetchRequestInit): Promise => { + bodies.push(String(init?.body ?? "")); + return new Response("{}", { status: 200 }); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock); + return bodies; +} + +function firstTimestamp(bodyText: string): string | undefined { + const body: unknown = JSON.parse(bodyText); + if (typeof body !== "object" || body === null) return undefined; + + const items = Object.getOwnPropertyDescriptor(body, "items")?.value; + if (!Array.isArray(items)) return undefined; + + const first = items[0]; + if (typeof first !== "object" || first === null) return undefined; + + const timestamp = Object.getOwnPropertyDescriptor(first, "timestamp")?.value; + return typeof timestamp === "string" ? timestamp : undefined; +} + +describe("HindsightApi timestamp serialization", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("serializes Date timestamps with the local timezone offset", async () => { + const bodies = captureRequestBodies(); + const client = new HindsightApi({ baseUrl: "http://hindsight.local" }); + + await client.retain("omp", "evening memory", { + timestamp: new Date(2026, 5, 12, 19, 17, 0), + }); + + const timestamp = firstTimestamp(bodies[0] ?? "{}"); + if (timestamp === undefined) throw new Error("Missing serialized timestamp"); + expect(timestamp).toMatch(/^2026-06-12T19:17:00[+-]\d{2}:\d{2}$/); + expect(timestamp.endsWith("Z")).toBe(false); + }); + + it("preserves caller-provided timestamp strings", async () => { + const bodies = captureRequestBodies(); + const client = new HindsightApi({ baseUrl: "http://hindsight.local" }); + + await client.retain("omp", "evening memory", { + timestamp: "2026-06-12T19:17:00+08:00", + }); + + expect(firstTimestamp(bodies[0] ?? "{}")).toBe("2026-06-12T19:17:00+08:00"); + }); +});