diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index f049c434a..229da9950 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `ToolExecutionApi.models` and `HookApi.models`: the Harness's `models`, so tools and hooks can resolve models and make requests with the same catalog, credentials, and request transforms as generation ([#10395](https://github.com/earendil-works/pi/issues/10395)). +- `Conversation.context(context, { at })` returns the model context as of a visible earlier entry, the same view `fork(at)` would start with, without creating a conversation ([#10512](https://github.com/earendil-works/pi/issues/10512)). ### Changed diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index bb6cec74f..14244bc08 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -539,7 +539,8 @@ interface Conversation { change: (tx: Tx) => T | Promise, context: Context, ): Promise; - context(context: Context): Promise; + /** With `at`, the context as of that visible entry, as `fork(at)` would start with. */ + context(context: Context, options?: { readonly at?: EntryId }): Promise; entries( query: Omit, limit: number, diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index ba8ebd68a..0edb09296 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -120,8 +120,8 @@ class ConversationImpl implements Conversation { return this.#host.harness.commitWith(change, context, { conversationId: this.id }); } - context(context: Context): Promise { - return readContext(this.#host.harness, this.#host.storage, this.id, context); + context(context: Context, options?: { readonly at?: EntryId }): Promise { + return readContext(this.#host.harness, this.#host.storage, this.id, context, options?.at); } entries( diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index a3344d2f9..9f60c7053 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -531,7 +531,11 @@ export interface Conversation { /** Session commit whose `tx.createTask()` defaults to this conversation. */ commit(change: (tx: Tx) => T | Promise, context: Context): Promise; - context(context: Context): Promise; + /** + * Committed raw active transcript and model context. With `at`, the context as of that visible entry: the same view + * `fork(at)` would start with, without creating a conversation. + */ + context(context: Context, options?: { readonly at?: EntryId }): Promise; /** Newest-first fork-aware history of this conversation. */ entries( query: Omit, diff --git a/packages/durable/test/harness-context.test.ts b/packages/durable/test/harness-context.test.ts index 29ff26761..a3779282e 100644 --- a/packages/durable/test/harness-context.test.ts +++ b/packages/durable/test/harness-context.test.ts @@ -142,6 +142,38 @@ describe("conversation context", () => { expect(parentView.entries.map((entry) => entry.kind)).toEqual(["reset", "message"]); }); + // #10512 + it("reads the context as of an earlier entry, as a fork at that entry starts", async () => { + const { root, append, message } = await setup(); + const first = await message(user("first")); + const call = await message(assistant("calling", { calls: ["x", "y"] })); + const result = await message(toolResult("x")); + await message(toolResult("y")); + const edit = await append({ + kind: "edit", + edits: [{ target: first.id, action: "replace", messages: [user("first v2")] }], + }); + const reset = await append({ kind: "reset", head: "self", model: [user("fresh start")] }); + const tail = await message(assistant("after reset")); + + for (const at of [first, call, result, edit, reset, tail]) { + const fork = await root.fork(at.id, { ownership: { kind: "ownerless" } }, context); + expect(await root.context(context, { at: at.id })).toEqual(await fork.context(context)); + } + // Stepped back before the edit and the reset: neither applies, and a cut call gets synthesized results. + expect((await root.context(context, { at: call.id })).messages.map(describeMessage)).toEqual([ + "user:first", + "assistant:calling", + "result:x:error", + "result:y:error", + ]); + expect(await root.context(context, { at: tail.id })).toEqual(await root.context(context)); + expect(await root.context(context, {})).toEqual(await root.context(context)); + + const other = await root.fork(first.id, { ownership: { kind: "ownerless" } }, context); + await expect(other.context(context, { at: tail.id })).rejects.toThrow("is not visible"); + }); + it("extends a task invocation's context read with only newer entries", async () => { let scanned = 0; const memory = new MemoryStorage();