Skip to content

Commit 25f4ac5

Browse files
committed
fix(session): treat dotted exactNames as whole tokens
Interior dots join filenames and hostnames so vite.config.ts does not cover config.ts. HTTP URLs lowercase before hostname scoring. Drop unused ContinuationFacts.state.
1 parent 00556a4 commit 25f4ac5

2 files changed

Lines changed: 135 additions & 39 deletions

File tree

‎src/session/compaction-verify.test.ts‎

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,119 @@ describe("verifyCompactionSummary", () => {
253253
true,
254254
);
255255
});
256+
257+
test("dotted names are whole tokens so suffixes and prefixes do not cover", () => {
258+
const cases: {
259+
tool: string;
260+
args: Record<string, string>;
261+
summary: string;
262+
detail: string;
263+
}[] = [
264+
{
265+
tool: "read_file",
266+
args: { path: "src/config.ts" },
267+
summary: "Fix auth now. Updated vite.config.ts.",
268+
detail: "src/config.ts",
269+
},
270+
{
271+
tool: "read_file",
272+
args: { path: "src/test.ts" },
273+
summary: "Fix auth now. Updated auth.test.ts.",
274+
detail: "src/test.ts",
275+
},
276+
{
277+
tool: "read_file",
278+
args: { path: "src/auth.ts" },
279+
summary: "Fix auth now. Updated foo.auth.ts.",
280+
detail: "src/auth.ts",
281+
},
282+
{
283+
tool: "read_file",
284+
args: { path: "src/auth.ts" },
285+
summary: "Fix auth now. Kept auth.ts.bak.",
286+
detail: "src/auth.ts",
287+
},
288+
{
289+
tool: "web_fetch",
290+
args: { url: "https://api.com/v1" },
291+
summary: "Fix auth now. Called www.api.com.",
292+
detail: "https://api.com/v1",
293+
},
294+
];
295+
for (const { tool, args, summary, detail } of cases) {
296+
const facts = extractContinuationFacts([
297+
textTurn("user", "Fix auth now"),
298+
{
299+
role: "assistant",
300+
content: [
301+
{
302+
type: "tool_call",
303+
id: "c1",
304+
name: tool,
305+
arguments: args,
306+
},
307+
],
308+
timestamp: 2,
309+
},
310+
]);
311+
const report = verifyCompactionSummary(summary, facts);
312+
expect(report.misses.some((m) => m.kind === "exactName")).toBe(true);
313+
expect(report.misses.some((m) => m.detail === detail)).toBe(true);
314+
}
315+
});
316+
317+
test("basename in a path still covers the exact name", () => {
318+
const facts = extractContinuationFacts([
319+
textTurn("user", "Fix auth now"),
320+
{
321+
role: "assistant",
322+
content: [
323+
{
324+
type: "tool_call",
325+
id: "c1",
326+
name: "read_file",
327+
arguments: { path: "src/auth.ts" },
328+
},
329+
],
330+
timestamp: 2,
331+
},
332+
]);
333+
const report = verifyCompactionSummary(
334+
"Fix auth now. Read auth.ts next.",
335+
facts,
336+
);
337+
expect(report.misses.some((m) => m.kind === "exactName")).toBe(false);
338+
});
339+
340+
test("uppercase HTTP URL scores hostname case-insensitively", () => {
341+
const facts = extractContinuationFacts([
342+
textTurn("user", "Fix auth now"),
343+
{
344+
role: "assistant",
345+
content: [
346+
{
347+
type: "tool_call",
348+
id: "c1",
349+
name: "web_fetch",
350+
arguments: { url: "HTTP://API.COM/v1" },
351+
},
352+
],
353+
timestamp: 2,
354+
},
355+
]);
356+
expect(
357+
verifyCompactionSummary(
358+
"Fix auth now. Called api.com.",
359+
facts,
360+
).misses.some((m) => m.kind === "exactName"),
361+
).toBe(false);
362+
expect(
363+
verifyCompactionSummary(
364+
"Fix auth now. Hit endpoint v1.",
365+
facts,
366+
).misses.some((m) => m.kind === "exactName"),
367+
).toBe(true);
368+
});
256369
});
257370

258371
describe("verifyOrRepair", () => {
@@ -360,7 +473,6 @@ describe("verifyOrRepair", () => {
360473
"Always keep the public API stable.",
361474
],
362475
nextAction: "Fix the token refresh assertion next.",
363-
state: "Fix the token refresh assertion next.",
364476
verification: ["bun run test auth", "bun run check"],
365477
blockers: ["token refresh assertion failed", "ECONNREFUSED on staging"],
366478
exactNames: ["src/auth.ts", "packages/runtime/config.json"],
@@ -389,7 +501,6 @@ describe("verifyOrRepair", () => {
389501
expect(repair).not.toContain("src/auth.ts");
390502
expect(repair).not.toContain("Goal:");
391503
expect(repair).not.toContain("Next:");
392-
expect(repair).not.toMatch(/^State:/m);
393504
});
394505

395506
test("repair lines are goal, next, exact names, then the rest", () => {
@@ -408,26 +519,6 @@ describe("verifyOrRepair", () => {
408519
expect(ranAt).toBeGreaterThan(blockersAt);
409520
});
410521

411-
test("repair does not emit a State line even when state is independent", () => {
412-
const facts = {
413-
goal: "Migrate the auth module to opaque tokens",
414-
constraints: [] as string[],
415-
nextAction: "Fix the token refresh assertion next.",
416-
state: "The cache is still cold after warmup.",
417-
verification: [] as string[],
418-
blockers: ["token refresh assertion failed"],
419-
exactNames: [] as string[],
420-
};
421-
const summary = "Work continues.";
422-
const misses = verifyCompactionSummary(summary, facts).misses;
423-
expect(misses.some((m) => m.kind === "blocker")).toBe(true);
424-
expect(misses.some((m) => m.kind === "nextAction")).toBe(true);
425-
const repaired = repairSummary(summary, facts, misses);
426-
const repair = repaired.slice(repaired.indexOf(VERIFY_REPAIR_HEADING));
427-
expect(repair).not.toMatch(/^State:/m);
428-
expect(repair).not.toContain("cache is still cold");
429-
});
430-
431522
test("a tight cap keeps goal, next, and names before aborting on the tail", () => {
432523
const facts = extractContinuationFacts(droppedTurns());
433524
const misses = verifyCompactionSummary("Work continues.", facts).misses;

‎src/session/compaction-verify.ts‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// After the compactor writes a summary handoff, this module scores the new
44
// spine against the continuation facts the dropped turns carried (goal,
5-
// state, next action, constraints, verification, blockers, exact names).
5+
// next action, constraints, verification, blockers, exact names).
66
// A fold that drops or contradicts those facts would leave the next agent
77
// without steam, so the pass repairs the handoff deterministically or aborts
88
// the fold. Fail closed: never ship a lying spine.
@@ -17,8 +17,6 @@ export const ContinuationFacts = type({
1717
constraints: "string[]",
1818
/** Last substantive assistant/user text in the dropped region. */
1919
nextAction: "string",
20-
/** Last error text or assistant snippet: where the work stood. */
21-
state: "string",
2220
/** Verification commands run (shell/test invocations). */
2321
verification: "string[]",
2422
/** Errored tool-result texts: what is still broken. */
@@ -127,9 +125,10 @@ function significantTokens(text: string, cap = 24): string[] {
127125

128126
// A strict majority of the fact's content words must appear in the summary.
129127
// Short facts need all of their words: one shared word proves nothing.
130-
// Match on token boundaries so "auth" does not score against "authored" or
131-
// hyphenated "pre-auth".
132-
function isTokenChar(ch: string | undefined): boolean {
128+
// Match on token boundaries so "auth" does not score against "authored",
129+
// hyphenated "pre-auth", or dotted "foo.auth". An interior `.` joins a
130+
// dotted name; a sentence period does not.
131+
function isNameChar(ch: string | undefined): boolean {
133132
if (ch === undefined) return false;
134133
const code = ch.charCodeAt(0);
135134
return (
@@ -140,14 +139,25 @@ function isTokenChar(ch: string | undefined): boolean {
140139
);
141140
}
142141

142+
function isTokenCharAt(text: string, index: number): boolean {
143+
if (index < 0 || index >= text.length) return false;
144+
const ch = text[index];
145+
if (isNameChar(ch)) return true;
146+
// `.` is a token char only between name chars: `vite.config.ts` stays
147+
// one token, but `tokens.` / `api.com.` still match at a sentence stop.
148+
return (
149+
ch === "." && isNameChar(text[index - 1]) && isNameChar(text[index + 1])
150+
);
151+
}
152+
143153
function tokenAppears(token: string, lowered: string): boolean {
144154
let from = 0;
145155
for (;;) {
146156
const i = lowered.indexOf(token, from);
147157
if (i < 0) return false;
148158
if (
149-
!isTokenChar(i === 0 ? undefined : lowered[i - 1]) &&
150-
!isTokenChar(lowered[i + token.length])
159+
!isTokenCharAt(lowered, i - 1) &&
160+
!isTokenCharAt(lowered, i + token.length)
151161
)
152162
return true;
153163
from = i + 1;
@@ -257,11 +267,6 @@ export function extractContinuationFacts(
257267
const goal = users[0] ?? "";
258268
const nextAction =
259269
assistants[assistants.length - 1] ?? users[users.length - 1] ?? "";
260-
const state =
261-
blockers[blockers.length - 1] ??
262-
assistants[assistants.length - 1] ??
263-
users[users.length - 1] ??
264-
"";
265270

266271
const exactNames: string[] = [];
267272
const verification: string[] = [];
@@ -298,7 +303,6 @@ export function extractContinuationFacts(
298303
goal: goal.slice(0, 500),
299304
constraints: extractConstraints(users),
300305
nextAction: nextAction.slice(0, 300),
301-
state: state.slice(0, 300),
302306
verification: verification.slice(0, 6),
303307
blockers,
304308
exactNames: exactNames.slice(0, 20),
@@ -310,11 +314,12 @@ export function extractContinuationFacts(
310314
// Basename for paths (a summary that moves `src/auth.ts` to "auth.ts" still
311315
// names it); hostname for URLs (query strings get reworded freely).
312316
// Match on token/path boundaries so "oauth.ts" does not cover "auth.ts",
313-
// "tsconfig.json" does not cover "config.json", and "myapi.com" does not
314-
// cover hostname "api.com".
317+
// "vite.config.ts" does not cover "config.ts", and "www.api.com" does not
318+
// cover hostname "api.com". `/` stays a non-token so a path still covers
319+
// its basename.
315320
function exactNameSupported(name: string, summary: string): boolean {
316321
const lowered = summary.toLowerCase();
317-
if (name.startsWith("http")) {
322+
if (name.toLowerCase().startsWith("http")) {
318323
const host = hostnameOf(name);
319324
if (host !== undefined && tokenAppears(host.toLowerCase(), lowered))
320325
return true;

0 commit comments

Comments
 (0)