Skip to content

Commit 2b67949

Browse files
committed
fix(session): enforce readable note size budget
1 parent b633994 commit 2b67949

4 files changed

Lines changed: 122 additions & 18 deletions

File tree

docs/SmokeTests.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,10 @@ the change that introduces it; do not assume one here, and do not invent a new
277277
`{ action: "deleted", id
278278
}`, exact single-note reads via
279279
`session_notes_read({ id })`, `{ note: null }` for unknown ids, and
280-
status-less response shapes.
280+
status-less response shapes. Non-empty `session_notes_write` calls that would
281+
make the eventual `session_notes_read` JSON exceed the shared 32 KB serialized
282+
response budget must be rejected before storage, while delete operations with
283+
empty text remain valid.
281284
- **Artifacts/evidence to save:** Full `deno test` output; failing test names if
282285
any; bounded serialized examples for each tool response; any type-check output
283286
from `deno task check`.
@@ -585,11 +588,13 @@ the change that introduces it; do not assume one here, and do not invent a new
585588
```
586589

587590
- **Expected result:** PASS. At minimum, automated coverage must continue to
588-
enforce the locked bounded-response budget, artifact spillover rules, bytes
589-
saved/accounting expectations, and no-unbounded-growth invariants already
590-
owned by the runtime and corpus tests. Any future latency or storage-growth
591-
numeric threshold added to the suite must be asserted in these existing test
592-
surfaces unless a separately justified harness is approved.
591+
enforce the locked 32 KB bounded-response budget, artifact spillover rules,
592+
bytes saved/accounting expectations, and no-unbounded-growth invariants
593+
already owned by the runtime and corpus tests. `session_notes_read` remains
594+
under the normal runtime guard, so accepted notes must stay readable within
595+
that shared limit. Any future latency or storage-growth numeric threshold
596+
added to the suite must be asserted in these existing test surfaces unless a
597+
separately justified harness is approved.
593598
- **Artifacts/evidence to save:** Full test output; any serialized payload-size
594599
assertions; corpus/artifact/stats counters relevant to storage growth; any
595600
threshold-failure logs added in future colocated tests.

docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ execution.
101101
- `session_execute`, `session_execute_file`, and `session_batch_execute` return
102102
a bounded human-readable summary plus references, never an unbounded raw
103103
payload.
104-
- Tool response body budget: 8 KB maximum serialized response payload per
104+
- Tool response body budget: 32 KB maximum serialized response payload per
105105
`session_*` call.
106106
- Large execution/fetch/file artifacts are stored locally and referenced by
107107
artifact or corpus ID.
@@ -281,8 +281,8 @@ Write failing tests first in `src/services/session-mcp-runtime.test.ts` and
281281
- each tool schema rejects calls without `root_session_id`
282282
- initial stub handlers return minimal valid responses for all 8 registered
283283
tools
284-
- response payloads are capped to the exact 8 KB response budget
285-
- at least one large-output case crossing the 8 KB boundary falls back to local
284+
- response payloads are capped to the exact 32 KB response budget
285+
- at least one large-output case crossing the 32 KB boundary falls back to local
286286
artifact storage/reference instead of returning an oversized inline payload
287287
- `session_batch_execute` executes sequentially in request order
288288
- `src/index.ts` wires runtime initialization and teardown in-process

src/services/session-mcp-runtime.test.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@ class DoctorRedisRuntime {
158158

159159
const textEncoder = new TextEncoder();
160160

161+
const createOversizedSessionNoteText = (): string => {
162+
const timestamp = "2026-04-11T10:00:00.000Z";
163+
const emptyPayloadBytes = textEncoder.encode(JSON.stringify({
164+
note: {
165+
id: crypto.randomUUID(),
166+
text: "",
167+
created_at: timestamp,
168+
updated_at: timestamp,
169+
},
170+
})).byteLength;
171+
return "x".repeat(
172+
SESSION_MCP_RESPONSE_BUDGET_BYTES - emptyPayloadBytes + 1,
173+
);
174+
};
175+
161176
const toolContext = {
162177
sessionID: "session-123",
163178
messageID: "message-123",
@@ -973,6 +988,62 @@ describe("session-mcp-runtime", () => {
973988
}
974989
});
975990

991+
it("rejects oversized note writes before storage and suggests splitting notes", async () => {
992+
const redis = new RedisClient({ endpoint: "redis://unused" });
993+
const runtime = createSessionMcpRuntime({
994+
redisClient: redis,
995+
sessionTtlSeconds: 60,
996+
} as never);
997+
const oversizedText = createOversizedSessionNoteText();
998+
999+
try {
1000+
await assertRejects(
1001+
() =>
1002+
runtime.tools.session_notes_write.execute(
1003+
{
1004+
text: oversizedText,
1005+
},
1006+
toolContext,
1007+
),
1008+
Error,
1009+
"multiple cross-referencing session notes",
1010+
);
1011+
} finally {
1012+
await runtime.dispose();
1013+
}
1014+
});
1015+
1016+
it("applies the shared response budget guard to session_notes_read", async () => {
1017+
const oversizedText = "x".repeat(SESSION_MCP_RESPONSE_BUDGET_BYTES + 1_024);
1018+
const runtime = createSessionMcpRuntime({
1019+
notesService: {
1020+
readNote: () =>
1021+
Promise.resolve({
1022+
note: {
1023+
id: "note-oversized",
1024+
text: oversizedText,
1025+
created_at: "2026-04-11T10:00:00.000Z",
1026+
updated_at: "2026-04-11T10:00:00.000Z",
1027+
},
1028+
}),
1029+
} as never,
1030+
} as never);
1031+
1032+
try {
1033+
await assertRejects(
1034+
() =>
1035+
runtime.tools.session_notes_read.execute(
1036+
{ id: "note-oversized" },
1037+
toolContext,
1038+
),
1039+
Error,
1040+
`session_notes_read response exceeded ${SESSION_MCP_RESPONSE_BUDGET_BYTES} bytes`,
1041+
);
1042+
} finally {
1043+
await runtime.dispose();
1044+
}
1045+
});
1046+
9761047
it("resolves rootless search and note writes from the canonical tool context session", async () => {
9771048
const redis = new RedisClient({ endpoint: "redis://unused" });
9781049
const manager = new SessionManager(
@@ -2124,7 +2195,7 @@ describe("session-mcp-runtime", () => {
21242195
}
21252196
});
21262197

2127-
it("caps serialized responses to the exact 8 KB budget", async () => {
2198+
it("caps serialized responses to the exact 32 KB budget", async () => {
21282199
const runtime = createSessionMcpRuntime();
21292200

21302201
try {
@@ -2145,7 +2216,7 @@ describe("session-mcp-runtime", () => {
21452216
}
21462217
});
21472218

2148-
it("falls back to a local artifact reference when inline output crosses 8 KB", async () => {
2219+
it("falls back to a local artifact reference when inline output crosses 32 KB", async () => {
21492220
const runtime = createSessionMcpRuntime({
21502221
handlers: {
21512222
session_execute: () =>
@@ -2272,11 +2343,11 @@ describe("session-mcp-runtime", () => {
22722343
Promise.resolve({
22732344
status: "ok",
22742345
summary: "SESSION TTL REPORT\n" +
2275-
"session ttl keeps local corpus search warm\n".repeat(400),
2346+
"session ttl keeps local corpus search warm\n".repeat(900),
22762347
exit_code: 0,
22772348
timed_out: false,
22782349
truncated: false,
2279-
bytes_captured: SESSION_MCP_RESPONSE_BUDGET_BYTES + 4_096,
2350+
bytes_captured: SESSION_MCP_RESPONSE_BUDGET_BYTES + 8_192,
22802351
}),
22812352
},
22822353
} as never);
@@ -2668,11 +2739,11 @@ describe("session-mcp-runtime", () => {
26682739
session_execute: (request: { command: string }) =>
26692740
Promise.resolve({
26702741
status: "ok",
2671-
summary: `${request.command}: ` + "x".repeat(6_000),
2742+
summary: `${request.command}: ` + "x".repeat(18_000),
26722743
exit_code: 0,
26732744
timed_out: false,
26742745
truncated: false,
2675-
bytes_captured: 6_010,
2746+
bytes_captured: 18_010,
26762747
}),
26772748
},
26782749
} as never);
@@ -2819,11 +2890,11 @@ describe("session-mcp-runtime", () => {
28192890
session_execute: (request: { command: string }) =>
28202891
Promise.resolve({
28212892
status: "ok",
2822-
summary: `${request.command}: ` + "x".repeat(7_000),
2893+
summary: `${request.command}: ` + "x".repeat(18_000),
28232894
exit_code: 0,
28242895
timed_out: false,
28252896
truncated: false,
2826-
bytes_captured: 7_010,
2897+
bytes_captured: 18_010,
28272898
}),
28282899
},
28292900
} as never);

src/services/session-mcp-runtime.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type { NormalizedMemoryResult } from "../types/index.ts";
3939
import { readFile as readFileNode } from "node:fs/promises";
4040
import path from "node:path";
4141

42-
export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 8 * 1024;
42+
export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 32 * 1024;
4343
const SESSION_SEARCH_RESULT_LIMIT = 5;
4444
const SEARCH_RESULT_CREATED_AT_FALLBACK = "1970-01-01T00:00:00.000Z";
4545

@@ -422,6 +422,15 @@ const createBoundedSessionIndexError = (
422422
const isWithinBudget = (value: string): boolean =>
423423
byteLength(value) <= SESSION_MCP_RESPONSE_BUDGET_BYTES;
424424

425+
const serializeSessionNoteReadResponse = (
426+
note: {
427+
id: string;
428+
text: string;
429+
created_at: string;
430+
updated_at: string;
431+
},
432+
): string => serialize({ note });
433+
425434
const resolveSessionIndexPath = (
426435
requestPath: string,
427436
context: ToolContext,
@@ -852,6 +861,25 @@ export const createSessionMcpRuntime = (
852861
},
853862
session_notes_write: async (request, context) => {
854863
const rootSessionId = await resolveCanonicalRootSessionId(context);
864+
if (request.text !== "") {
865+
const timestamp = new Date().toISOString();
866+
const existingNote = request.replace && request.replace !== "*"
867+
? (await notes.readNotes(rootSessionId, request.replace)).notes[0]
868+
: undefined;
869+
const previewNote = {
870+
id: request.replace && request.replace !== "*"
871+
? request.replace
872+
: crypto.randomUUID(),
873+
text: request.text,
874+
created_at: existingNote?.created_at ?? timestamp,
875+
updated_at: timestamp,
876+
};
877+
if (!isWithinBudget(serializeSessionNoteReadResponse(previewNote))) {
878+
throw new Error(
879+
"session_notes_write note would exceed the shared response budget when read back; break the content into multiple cross-referencing session notes.",
880+
);
881+
}
882+
}
855883
return await notes.writeNote(rootSessionId, request.text, {
856884
replace: request.replace,
857885
});

0 commit comments

Comments
 (0)