Skip to content

Commit 4fac105

Browse files
committed
fix: D17 hardening — fix always-true hasPiFallbackToolOwnerTags gate + 3 tests
Follow-up to the D17 Pi tool-tag owner cutover (5fd440d). The post-merge adversarial diff-Oracle (SHIP, 0 correctness defects) flagged non-blocking test gaps; adding the cheap-gate coverage test EXPOSED a real bug it had missed: hasPiFallbackToolOwnerTags returned `row !== undefined`, but SQLite `.get()` yields NULL (not the JS undefined sentinel) for a no-row result — so the gate was ALWAYS true. Correctness was unaffected (findPiFallbackToolOwnerTags returns the real, empty rows), but the MF-D perf guard was fully defeated: the buildPiToolOwnerMap branch-walk (resolveStableId over every message) + the tool-tag scan ran on EVERY Pi pass for EVERY session — exactly the O(session) hot-path cost MF-D existed to eliminate. Fixed to `!= null`. Hardened the sibling same-class instance tagTokenCountIsNull, where the bug was latent-worse: `row !== undefined && row.token_count` would have crashed on `null.token_count` if ever called for a missing tag. Added the 3 hardening tests the Oracle recommended: duplicate-only pending-op retarget onto a survivor lacking the op, no-timestamp owner skip, and the cheap-gate assertion (which is what surfaced the bug). The cheap-gate test now also asserts hasPiFallbackToolOwnerTags(...) === false directly. Gate: plugin 2204/0, Pi 487/0 (+3 tests), tsc + biome clean.
1 parent 5fd440d commit 4fac105

2 files changed

Lines changed: 125 additions & 3 deletions

File tree

packages/pi-plugin/src/context-handler.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getPendingOps,
1919
getPendingPiCompactionMarkerState,
2020
getTagsBySession,
21+
hasPiFallbackToolOwnerTags,
2122
incrementHistorianFailure,
2223
insertTag,
2324
queuePendingOp,
@@ -712,6 +713,119 @@ describe("Pi fallback tag adoption", () => {
712713
closeQuietly(db);
713714
}
714715
});
716+
717+
it("retargets a duplicate-only pending op onto a survivor that lacks it", () => {
718+
const db = createTestDb();
719+
try {
720+
const sessionId = "ses-pi-tool-owner-pending-only-dup";
721+
const tagger = createTagger();
722+
const callId = "call-pending-only";
723+
const piOwner = "pi-msg-0-90-assistant";
724+
const realOwner = "entry-tool-pending-only";
725+
// Survivor (synthetic) has NO pending op; the duplicate (real re-read) does.
726+
insertTag(db, sessionId, callId, "tool", 10, 90, 0, "Read", 0, piOwner);
727+
insertTag(db, sessionId, callId, "tool", 20, 91, 0, "Read", 0, realOwner);
728+
queuePendingOp(db, sessionId, 91, "drop", 110);
729+
tagger.bindToolTag(sessionId, callId, piOwner, 90);
730+
tagger.bindToolTag(sessionId, callId, realOwner, 91);
731+
732+
contextHandlerInternals.adoptPiFallbackTags(
733+
db,
734+
sessionId,
735+
tagger,
736+
new Map(),
737+
{
738+
messages: [assistantToolCall(callId, "Read", {}, 90)],
739+
resolveStableId: () => realOwner,
740+
},
741+
);
742+
743+
// The duplicate is gone and its pending op moved onto the survivor (90),
744+
// which had none — proving retarget, not silent loss.
745+
expect(readTagRow(db, sessionId, 91)).toBeUndefined();
746+
expect(readTagRow(db, sessionId, 90)?.status).toBe("active");
747+
expect(getPendingOps(db, sessionId).map((op) => op.tagId)).toEqual([90]);
748+
} finally {
749+
closeQuietly(db);
750+
}
751+
});
752+
753+
it("skips a no-timestamp fallback owner instead of rekeying it", () => {
754+
const db = createTestDb();
755+
try {
756+
const sessionId = "ses-pi-tool-owner-no-ts";
757+
const tagger = createTagger();
758+
const callId = "call-no-ts";
759+
// No-timestamp synthetic owner form: pi-msg-${index}-${role} (no ts segment).
760+
const piOwner = "pi-msg-0-assistant";
761+
insertTag(db, sessionId, callId, "tool", 10, 95, 0, "Read", 0, piOwner);
762+
tagger.bindToolTag(sessionId, callId, piOwner, 95);
763+
764+
contextHandlerInternals.adoptPiFallbackTags(
765+
db,
766+
sessionId,
767+
tagger,
768+
new Map(),
769+
{
770+
messages: [assistantToolCall(callId, "Read", {}, 90)],
771+
resolveStableId: () => "entry-no-ts",
772+
},
773+
);
774+
775+
// Unmatchable by (ts,callID) → left as-is, never wrong-rekeyed.
776+
expect(readTagRow(db, sessionId, 95)?.toolOwnerMessageId).toBe(piOwner);
777+
expect(
778+
tagger.getToolTag(sessionId, callId, "entry-no-ts"),
779+
).toBeUndefined();
780+
} finally {
781+
closeQuietly(db);
782+
}
783+
});
784+
785+
it("cheap-gate: does not build the owner map when no pi-msg tool owners exist", () => {
786+
const db = createTestDb();
787+
try {
788+
const sessionId = "ses-pi-tool-owner-cheap-gate";
789+
const tagger = createTagger();
790+
// Only a real-owner tool tag exists — no pi-msg-* owners to migrate.
791+
insertTag(
792+
db,
793+
sessionId,
794+
"call-real",
795+
"tool",
796+
10,
797+
96,
798+
0,
799+
"Read",
800+
0,
801+
"entry-real",
802+
);
803+
// Split a gate hole from a wrong test premise: the tool-owner gate MUST
804+
// be false here (no pi-msg-* owners), so the branch-walk never runs.
805+
expect(hasPiFallbackToolOwnerTags(db, sessionId)).toBe(false);
806+
807+
let resolverCalls = 0;
808+
contextHandlerInternals.adoptPiFallbackTags(
809+
db,
810+
sessionId,
811+
tagger,
812+
new Map(),
813+
{
814+
messages: [assistantToolCall("call-real", "Read", {}, 90)],
815+
resolveStableId: () => {
816+
resolverCalls += 1;
817+
return "entry-real";
818+
},
819+
},
820+
);
821+
822+
// The cheap hasPiFallbackToolOwnerTags gate short-circuits before any
823+
// branch walk, so the resolver is never consulted.
824+
expect(resolverCalls).toBe(0);
825+
} finally {
826+
closeQuietly(db);
827+
}
828+
});
715829
});
716830

717831
describe("registerPiContextHandler", () => {

packages/plugin/src/features/magic-context/storage-tags.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,11 @@ export function updateTagInputTokenCount(
475475
export function tagTokenCountIsNull(db: Database, sessionId: string, tagNumber: number): boolean {
476476
const row = db
477477
.prepare("SELECT token_count FROM tags WHERE session_id = ? AND tag_number = ?")
478-
.get(sessionId, tagNumber) as { token_count: number | null } | undefined;
479-
return row !== undefined && row.token_count === null;
478+
.get(sessionId, tagNumber) as { token_count: number | null } | undefined | null;
479+
// `!= null` guards a no-row result (`.get()` yields null, not the JS undefined
480+
// sentinel) — `row !== undefined` alone would let that null through to
481+
// `null.token_count` and crash.
482+
return row != null && row.token_count === null;
480483
}
481484

482485
/**
@@ -1077,7 +1080,12 @@ export function hasPiFallbackToolOwnerTags(db: Database, sessionId: string): boo
10771080
LIMIT 1`,
10781081
)
10791082
.get(sessionId);
1080-
return row !== undefined;
1083+
// `.get()` returns NULL for a no-row result (empirically true under bun:sqlite;
1084+
// node:sqlite likewise never returns the JS `undefined` sentinel) — so
1085+
// `row !== undefined` is TRUE for an EMPTY result, which would defeat this cheap
1086+
// pre-gate and run the per-pass tool-owner branch-walk for EVERY session. Use
1087+
// `!= null` to treat both null and undefined as "no row".
1088+
return row != null;
10811089
}
10821090

10831091
export function findPiFallbackToolOwnerTags(

0 commit comments

Comments
 (0)