-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(session): extract Session.isDescendantOf from auto-reply walk (F13) #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
980f5e0
935f474
f54a1ad
aed4e5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -375,6 +375,23 @@ export interface Interface { | |
| sessionID: SessionID, | ||
| predicate: (msg: MessageV2.WithParts) => boolean, | ||
| ) => Effect.Effect<Option.Option<MessageV2.WithParts>> | ||
| /** | ||
| * Returns true if `sid` is `root` or transitively descends from `root` via | ||
| * `parentID`. Walks the parent chain up to `opts.maxDepth` (default 64) and | ||
| * stops at any `NotFoundError` (returns false). When `opts.cache` is | ||
| * provided, it is used both as a positive-hit short-circuit and as an | ||
| * accumulator: every confirmed descendant in the walked chain is added to | ||
| * the set. The cache is auto-seeded with `root` on every call, so callers | ||
| * can pass a fresh `new Set()` and reuse it across calls without seeding. | ||
| * Callers that issue many `isDescendantOf` calls against the same root | ||
| * (e.g. SessionAutoReply) should reuse one cache so the parent chain is | ||
| * traversed at most once per node. | ||
| */ | ||
| readonly isDescendantOf: ( | ||
| sid: SessionID, | ||
| root: SessionID, | ||
| opts?: { maxDepth?: number; cache?: Set<SessionID> }, | ||
| ) => Effect.Effect<boolean> | ||
| } | ||
|
|
||
| export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {} | ||
|
|
@@ -671,6 +688,45 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = | |
| return Option.none<MessageV2.WithParts>() | ||
| }) | ||
|
|
||
| const isDescendantOf = Effect.fn("Session.isDescendantOf")(function* ( | ||
| sid: SessionID, | ||
| root: SessionID, | ||
| opts?: { maxDepth?: number; cache?: Set<SessionID> }, | ||
| ) { | ||
| const maxDepth = opts?.maxDepth ?? 64 | ||
| const known = opts?.cache ?? new Set<SessionID>() | ||
| // Always seed `root` into the working set so the parent walk has a | ||
| // termination anchor regardless of whether the caller pre-seeded the | ||
| // cache. Without this, a caller-provided cache that happens to omit | ||
| // `root` would cause the walk to bottom out at a not-found parent and | ||
| // return false even for true descendants — a silent footgun. | ||
| known.add(root) | ||
| if (sid === root) return true | ||
| if (known.has(sid)) return true | ||
| // Walk parent chain. Track the chain so we can promote every visited | ||
|
Comment on lines
+703
to
+722
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f54a1ad. At call entry, if opts.cache is non-empty AND does not contain root, isDescendantOf now dies with a clear error naming the expected root and the cache size. New regression test 'dies if cache is non-empty but missing root' pins the guard. JSDoc updated. Picked option (a) over (b) because Map<root,Set> changes the API for the AutoReply use case where a single cache is correct, while the guard catches the actual bug class without complicating the simple case. |
||
| // node into the cache once we hit a known descendant — turns repeated | ||
| // calls against the same lineage into O(1) after the first walk. | ||
| const chain: SessionID[] = [] | ||
| let cur: SessionID | undefined = sid | ||
| let depth = 0 | ||
| while (cur !== undefined && !known.has(cur) && depth < maxDepth) { | ||
| chain.push(cur) | ||
| depth++ | ||
| const lookup: Option.Option<Info> = yield* get(cur).pipe( | ||
| Effect.option, | ||
| Effect.catchDefect((defect) => { | ||
| if (!NotFoundError.isInstance(defect)) return Effect.die(defect) | ||
| return Effect.succeed(Option.none<Info>()) | ||
| }), | ||
| ) | ||
| if (Option.isNone(lookup)) break | ||
| cur = lookup.value.parentID ?? undefined | ||
| } | ||
| if (cur === undefined || !known.has(cur)) return false | ||
| chain.forEach((item) => known.add(item)) | ||
| return true | ||
| }) | ||
|
|
||
| return Service.of({ | ||
| create, | ||
| fork, | ||
|
|
@@ -693,6 +749,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = | |
| getPart, | ||
| updatePartDelta, | ||
| findMessage, | ||
| isDescendantOf, | ||
| }) | ||
| }), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus" | |
| import { Log } from "../../src/util" | ||
| import { Instance } from "../../src/project/instance" | ||
| import { MessageV2 } from "../../src/session/message-v2" | ||
| import { MessageID, PartID, type SessionID } from "../../src/session/schema" | ||
| import { MessageID, PartID, SessionID } from "../../src/session/schema" | ||
| import { AppRuntime } from "../../src/effect/app-runtime" | ||
| import { tmpdir } from "../fixture/fixture" | ||
|
|
||
|
|
@@ -179,3 +179,139 @@ describe("Session", () => { | |
| expect(missing).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| function isDescendantOf( | ||
| sid: SessionID, | ||
| root: SessionID, | ||
| opts?: { maxDepth?: number; cache?: Set<SessionID> }, | ||
| ) { | ||
| return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.isDescendantOf(sid, root, opts))) | ||
| } | ||
|
|
||
| describe("Session.isDescendantOf", () => { | ||
| test("identity: a session is its own descendant", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| expect(await isDescendantOf(root.id, root.id)).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("direct child is a descendant", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| const child = await create({ title: "child", parentID: root.id }) | ||
| expect(await isDescendantOf(child.id, root.id)).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("grandchild is a descendant", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| const child = await create({ title: "child", parentID: root.id }) | ||
| const grandchild = await create({ title: "grandchild", parentID: child.id }) | ||
| expect(await isDescendantOf(grandchild.id, root.id)).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("unrelated session is not a descendant", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const rootA = await create({ title: "rootA" }) | ||
| const rootB = await create({ title: "rootB" }) | ||
| expect(await isDescendantOf(rootB.id, rootA.id)).toBe(false) | ||
| await remove(rootA.id) | ||
| await remove(rootB.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("non-existent session id is not a descendant", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| const ghost = SessionID.make("ses_ghost_does_not_exist_00000") | ||
| expect(await isDescendantOf(ghost, root.id)).toBe(false) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("respects maxDepth: returns false when chain is deeper than the limit", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| // Build a chain root → c1 → c2 → c3. | ||
| const c1 = await create({ title: "c1", parentID: root.id }) | ||
| const c2 = await create({ title: "c2", parentID: c1.id }) | ||
| const c3 = await create({ title: "c3", parentID: c2.id }) | ||
| // maxDepth: 1 means we walk at most one parent edge from c3, which | ||
| // reaches c2 (not yet known), so we cannot confirm root and must | ||
| // return false. With the default depth (64), c3 is reachable. | ||
| expect(await isDescendantOf(c3.id, root.id, { maxDepth: 1 })).toBe(false) | ||
| expect(await isDescendantOf(c3.id, root.id)).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("cache accumulates confirmed descendants across calls", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| const c1 = await create({ title: "c1", parentID: root.id }) | ||
| const c2 = await create({ title: "c2", parentID: c1.id }) | ||
| const cache = new Set<SessionID>([root.id]) | ||
| // First call walks the full chain c2 → c1 → root and promotes both | ||
| // intermediate nodes into the cache. | ||
| expect(await isDescendantOf(c2.id, root.id, { cache })).toBe(true) | ||
| expect(cache.has(c1.id)).toBe(true) | ||
| expect(cache.has(c2.id)).toBe(true) | ||
| // A subsequent call for c1 short-circuits via the cache (depth: 0 | ||
| // forbids any walk; cache hit alone must satisfy the call). | ||
| expect(await isDescendantOf(c1.id, root.id, { cache, maxDepth: 0 })).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("auto-seeds root into cache so callers can pass an empty Set", async () => { | ||
| await using tmp = await tmpdir({ git: true }) | ||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const root = await create({ title: "root" }) | ||
| const child = await create({ title: "child", parentID: root.id }) | ||
| // Caller passes a fresh empty Set — root must still be reachable. | ||
| // Without the auto-seed in isDescendantOf this would walk past root, | ||
| // hit a not-found parent, and return false. | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f54a1ad — wording now says 'walk PAST root (since known.has(root) would be false), bottom out at the not-found parent of root, and return false', which matches the actual code path. |
||
| const cache = new Set<SessionID>() | ||
| expect(await isDescendantOf(child.id, root.id, { cache })).toBe(true) | ||
| expect(cache.has(root.id)).toBe(true) | ||
| await remove(root.id) | ||
| }, | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch on the lost trace span. Restored in aed4e5a — wrapped the call in Effect.fn("SessionAutoReply.isDescendant") so traces still show the AutoReply-specific span alongside the inner Session.isDescendantOf span. No behavior change.