Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface StoredResponseState {
/** v1 Cursor-only metadata, accepted only while loading old snapshots. */
conversationId?: string;
cursorCheckpointUsable?: boolean;
/** Approximate in-memory size, computed locally at insert time (never trusted from disk). */
/** Serialized UTF-8 entry size, computed locally at insert time (never trusted from disk). */
sizeBytes?: number;
}

Expand All @@ -49,21 +49,28 @@ export function getStoredResponseBytesForTests(): number {
return storedResponseBytes;
}

/** The ONLY size computation: approximate entry weight from its items payload. */
function measuredEntry(entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState {
let sizeBytes = 0;
/** The ONLY size computation: serialized UTF-8 bytes retained by the map entry. */
function measuredEntry(id: string, entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState | null {
try {
sizeBytes = JSON.stringify(entry.items).length;
const serialized = JSON.stringify([id, entry]);
return { ...entry, sizeBytes: Buffer.byteLength(serialized, "utf8") };
} catch {
/* unserializable items: weightless rather than fatal */
// A value whose retained size cannot be measured cannot safely enter a byte-bounded store.
return null;
}
return { ...entry, sizeBytes };
}

/** The ONLY insertion point: keeps the byte counter consistent on replacement. */
/**
* The ONLY insertion point: measure before mutating, reject an over-cap candidate without
* evicting unrelated valid chains, and remove a stale same-id value before rejecting it.
*/
function setEntry(id: string, entry: Omit<StoredResponseState, "sizeBytes">): void {
const measured = measuredEntry(id, entry);
if (!measured || (measured.sizeBytes ?? 0) > byteCap()) {
deleteEntry(id);
return;
}
deleteEntry(id);
const measured = measuredEntry(entry);
storedResponseBytes += measured.sizeBytes ?? 0;
states.set(id, measured);
}
Expand Down Expand Up @@ -326,7 +333,7 @@ function pruneResponses(at = now()): void {
deleteEntry(oldest);
}
// Byte high-water eviction, oldest-first (Map preserves insertion order).
while (storedResponseBytes > byteCap() && states.size > 1) {
while (storedResponseBytes > byteCap() && states.size > 0) {
const oldest = states.keys().next().value;
if (!oldest) break;
deleteEntry(oldest);
Expand Down Expand Up @@ -371,7 +378,7 @@ export function previousResponseProviderState(responseId: string | undefined): O
export interface ResponseStateMetrics {
/** Number of retained continuation entries currently in RAM. */
count: number;
/** Sum of serialized item bytes across all entries (the running in-memory byte accounting).
/** Sum of serialized map-entry bytes across all entries (the running in-memory byte accounting).
* Because expanded previous_response_id chains share prefix item references, this is an UPPER
* bound on true heap (it re-counts shared history), never an under-count — safe as a
* memory-pressure signal. */
Expand Down
12 changes: 12 additions & 0 deletions structure/02_config-and-codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ are consumed incrementally and at most 512 stale files are attempted per process
- 다른 대안 대신 이 방식을 선택한 이유: It repairs known remnants without broad authority over unrelated temp files or active writers.
- 장점, 단점 및 영향: Old dead-PID files are reclaimed automatically; locked or conservatively classified files remain for a later retry.

In-memory continuation entries are measured in serialized UTF-8 bytes before admission. An entry
larger than the 64 MiB store budget is not retained, and replacing an existing response ID with an
oversized value removes the stale value without evicting unrelated valid chains.

[Decision Log]
- 목적과 의도: Make the Responses continuation byte budget a hard invariant even for one image-heavy or malformed entry.
- 기존 구현 및 제약 조건: Oldest-first pruning stopped when one entry remained, so a single value could exceed the nominal 64 MiB cap; continuation misses are recoverable but unbounded retention is not.
- 검토한 주요 대안: Keep the newest entry regardless of size; evict all other entries before admitting it; reject the candidate atomically.
- 선택한 방식: Measure UTF-8 bytes before mutation, reject an over-budget candidate, and remove only a stale entry with the same response ID.
- 다른 대안 대신 이 방식을 선택한 이유: Evicting unrelated chains cannot make an individually oversized value fit, while retaining it breaks the advertised memory bound.
- 장점, 단점 및 영향: RAM accounting remains at or below the cap; an oversized continuation becomes a normal previous-response miss and may require the client to resend history.

## Config surface

`src/types.ts` is the shape and `src/config.ts` is the loader; neither is reproduced here. What
Expand Down
92 changes: 91 additions & 1 deletion tests/responses-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,94 @@ describe("Responses previous_response_id state", () => {
}
});

test("a single over-cap entry is rejected without evicting an unrelated valid chain", () => {
setResponseStateByteCapForTests(1_000);
try {
const small = buildResponseJSON([
{ type: "text_delta", text: "ok" },
{ type: "done" },
], "gpt-5.5");
rememberResponseState({ model: "gpt-5.5", input: "small" }, small);
const smallBytes = getStoredResponseBytesForTests();

const oversized = buildResponseJSON([
{ type: "text_delta", text: "x".repeat(3_000) },
{ type: "done" },
], "gpt-5.5");
rememberResponseState({ model: "gpt-5.5", input: "too large" }, oversized);

expect(expandPreviousResponseInput({
model: "gpt-5.5", previous_response_id: oversized.id, input: "next",
})).toEqual({ model: "gpt-5.5", previous_response_id: oversized.id, input: "next" });
expect((expandPreviousResponseInput({
model: "gpt-5.5", previous_response_id: small.id, input: "next",
}) as { input: unknown[] }).input).toHaveLength(3);
expect(getStoredResponseBytesForTests()).toBe(smallBytes);
expect(responseStateMetrics().totalBytes).toBeLessThanOrEqual(1_000);
} finally {
setResponseStateByteCapForTests(null);
}
});

test("an over-cap replacement removes stale state for the same response id", () => {
setResponseStateByteCapForTests(1_000);
try {
const responseId = "resp_replaced_over_cap";
rememberResponseState(
{ model: "gpt-5.5", input: "small" },
{ id: responseId, status: "completed", output: [{ type: "message", content: "ok" }] },
);
expect(responseStateMetrics().count).toBe(1);

rememberResponseState(
{ model: "gpt-5.5", input: "replacement" },
{ id: responseId, status: "completed", output: [{ type: "message", content: "x".repeat(3_000) }] },
);

const next = { model: "gpt-5.5", previous_response_id: responseId, input: "next" };
expect(expandPreviousResponseInput(next)).toEqual(next);
expect(responseStateMetrics()).toEqual({ count: 0, totalBytes: 0, largestBytes: 0, oldestAgeMs: 0 });
} finally {
setResponseStateByteCapForTests(null);
}
});

test("byte admission counts UTF-8 bytes instead of JavaScript code units", () => {
setResponseStateByteCapForTests(1_000);
try {
const responseId = "resp_multibyte_over_cap";
rememberResponseState(
{ model: "gpt-5.5", input: "한".repeat(400) },
{ id: responseId, status: "completed", output: [] },
);

const next = { model: "gpt-5.5", previous_response_id: responseId, input: "next" };
expect(expandPreviousResponseInput(next)).toEqual(next);
expect(getStoredResponseBytesForTests()).toBe(0);
} finally {
setResponseStateByteCapForTests(null);
}
});

test("snapshot loading rejects a single entry above the active byte cap", () => {
setResponseStateByteCapForTests(1_000);
try {
mkdirSync(home, { recursive: true });
writeFileSync(join(home, "responses-state.json"), JSON.stringify({
version: 2,
states: [["resp_snapshot_over_cap", {
createdAt: Date.now(),
items: [{ role: "user", content: "x".repeat(3_000) }],
}]],
}));

expect(previousResponseProviderState("resp_snapshot_over_cap")).toBeUndefined();
expect(responseStateMetrics()).toEqual({ count: 0, totalBytes: 0, largestBytes: 0, oldestAgeMs: 0 });
} finally {
setResponseStateByteCapForTests(null);
}
});

test("byte accounting survives restart (sizes recomputed on load)", async () => {
setResponseStateByteCapForTests(4_000);
try {
Expand Down Expand Up @@ -609,7 +697,9 @@ describe("Responses previous_response_id state", () => {
for (let i = 0; i < 1_050; i++) {
const body = { model: "cursor/grok-4.5", input: `${bulk}-000${String(i % 10)}`, store: false };
const json = buildResponseJSON([{ type: "text_delta", text: "ok" }, { type: "done" }], "cursor/grok-4.5");
rememberResponseState(body, json, { cursor: { conversationId: `conv_c${i}` } }, { force: true });
rememberResponseState(body, json, {
cursor: { conversationId: `conv_c${String(i).padStart(4, "0")}` },
}, { force: true });
lastId = json.id as string;
if (perEntryBytes === 0) perEntryBytes = getStoredResponseBytesForTests();
}
Expand Down
Loading