feat(durable): read a conversation's context as of an earlier entry

closes #10512
This commit is contained in:
Mario Zechner 2026-10-06 17:22:05 +02:00
parent b0114ef5fa
commit 76f6c06daf
5 changed files with 42 additions and 4 deletions

View file

@ -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

View file

@ -539,7 +539,8 @@ interface Conversation {
change: (tx: Tx) => T | Promise<T>,
context: Context,
): Promise<T>;
context(context: Context): Promise<ContextView>;
/** With `at`, the context as of that visible entry, as `fork(at)` would start with. */
context(context: Context, options?: { readonly at?: EntryId }): Promise<ContextView>;
entries(
query: Omit<EntryQuery, "conversationId">,
limit: number,

View file

@ -120,8 +120,8 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
return this.#host.harness.commitWith(change, context, { conversationId: this.id });
}
context(context: Context): Promise<ContextView> {
return readContext(this.#host.harness, this.#host.storage, this.id, context);
context(context: Context, options?: { readonly at?: EntryId }): Promise<ContextView> {
return readContext(this.#host.harness, this.#host.storage, this.id, context, options?.at);
}
entries(

View file

@ -531,7 +531,11 @@ export interface Conversation {
/** Session commit whose `tx.createTask()` defaults to this conversation. */
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
context(context: Context): Promise<ContextView>;
/**
* 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<ContextView>;
/** Newest-first fork-aware history of this conversation. */
entries(
query: Omit<EntryQuery, "conversationId">,

View file

@ -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();