fix(hindsight): preserved local retain timestamps

Sent Hindsight retain timestamps with local timezone offsets and supplied timestamps for automatic and queued retains. Added regression coverage for client serialization and backend timestamp propagation.\n\nFixes #2363
This commit is contained in:
roboomp
2026-06-12 06:54:30 +00:00
parent 1308f654a0
commit b94ab98005
5 changed files with 95 additions and 3 deletions
+1
View File
@@ -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)
+26 -1
View File
@@ -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, unknown>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
+6 -2
View File
@@ -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<void> {
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,
});
}
@@ -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
@@ -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<Response> => {
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");
});
});