Skip to content

Commit 766166b

Browse files
steipetescotthuang
andcommitted
fix(agents): validate explicit side-branch cursors
Co-authored-by: scotthuang <[email protected]>
1 parent 10bf347 commit 766166b

2 files changed

Lines changed: 79 additions & 1 deletion

File tree

src/agents/sessions/session-manager.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,6 +2042,83 @@ describe("SessionManager.open", () => {
20422042
expect(sessionManager.getEntries()).toHaveLength(entryCount);
20432043
});
20442044

2045+
it("rejects an unowned side leaf that resets a non-root side cursor", async () => {
2046+
const dir = await makeTempDir();
2047+
const sessionManager = SessionManager.create(dir, dir);
2048+
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
2049+
const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
2050+
const entryCount = sessionManager.getEntries().length;
2051+
const sideEntry = {
2052+
type: "message" as const,
2053+
id: "side-delivery-before-root-reset",
2054+
parentId: activeLeafId,
2055+
timestamp: "2026-07-05T00:00:01.000Z",
2056+
message: buildAssistantMessage("side delivery"),
2057+
};
2058+
2059+
expect(() =>
2060+
sessionManager.mergePromptReleasedSessionEntries([
2061+
sideEntry,
2062+
{
2063+
type: "prompt_released_opaque",
2064+
preserveActiveLeaf: true,
2065+
record: {
2066+
type: "leaf",
2067+
id: "unowned-root-reset-leaf",
2068+
parentId: sideEntry.id,
2069+
timestamp: "2026-07-05T00:00:02.000Z",
2070+
targetId: activeLeafId,
2071+
appendParentId: null,
2072+
appendMode: "side",
2073+
},
2074+
},
2075+
]),
2076+
).toThrow("prompt-released side leaf changed the active branch");
2077+
expect(sessionManager.getLeafId()).toBe(activeLeafId);
2078+
expect(sessionManager.getEntries()).toHaveLength(entryCount);
2079+
});
2080+
2081+
it("accepts an explicit root side cursor when it matches the current side branch", async () => {
2082+
const dir = await makeTempDir();
2083+
const sessionManager = SessionManager.create(dir, dir);
2084+
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
2085+
const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
2086+
2087+
sessionManager.mergePromptReleasedSessionEntries([
2088+
{
2089+
type: "prompt_released_opaque",
2090+
record: {
2091+
type: "leaf",
2092+
id: "owned-root-side-leaf",
2093+
parentId: activeLeafId,
2094+
timestamp: "2026-07-05T00:00:01.000Z",
2095+
targetId: activeLeafId,
2096+
appendParentId: null,
2097+
appendMode: "side",
2098+
},
2099+
},
2100+
]);
2101+
2102+
expect(() =>
2103+
sessionManager.mergePromptReleasedSessionEntries([
2104+
{
2105+
type: "prompt_released_opaque",
2106+
preserveActiveLeaf: true,
2107+
record: {
2108+
type: "leaf",
2109+
id: "unowned-root-side-leaf",
2110+
parentId: null,
2111+
timestamp: "2026-07-05T00:00:02.000Z",
2112+
targetId: activeLeafId,
2113+
appendParentId: null,
2114+
appendMode: "side",
2115+
},
2116+
},
2117+
]),
2118+
).not.toThrow();
2119+
expect(sessionManager.getLeafId()).toBe(activeLeafId);
2120+
});
2121+
20452122
it("applies merged leaf controls across separate callbacks", async () => {
20462123
const dir = await makeTempDir();
20472124
const sessionManager = SessionManager.create(dir, dir);

src/agents/sessions/session-manager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2342,7 +2342,8 @@ export class SessionManager {
23422342
}
23432343
const leaf = parseOpaqueLeafEntry(entry.record);
23442344
if (leaf && entry.preserveActiveLeaf) {
2345-
const appendParentId = leaf.appendParentId ?? leaf.targetId;
2345+
const appendParentId =
2346+
leaf.appendParentId === undefined ? leaf.targetId : leaf.appendParentId;
23462347
if (
23472348
leaf.appendMode !== "side" ||
23482349
leaf.targetId !== this.leafId ||

0 commit comments

Comments
 (0)