From c9adb1abefbff93a920580b9b58f38e02fa48a10 Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Fri, 23 Oct 2026 17:40:30 +0200 Subject: [PATCH 1/7] feat(ai-harness): session.seed starts an empty thread with existing messages Co-Authored-By: Claude Opus 5.5 --- .changeset/harness-session-seed.md | 5 + packages/ai-harness/src/host.ts | 19 +- packages/ai-harness/src/session/handle.ts | 2 + .../ai-harness/src/session/harness-session.ts | 1 + packages/ai-harness/src/session/helpers.ts | 18 ++ packages/ai-harness/src/session/state.ts | 63 +++++ packages/ai-harness/tests/seed.test.ts | 216 ++++++++++++++++++ 7 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 .changeset/harness-session-seed.md create mode 100644 packages/ai-harness/tests/seed.test.ts diff --git a/.changeset/harness-session-seed.md b/.changeset/harness-session-seed.md new file mode 100644 index 0000000000..32d6eaa113 --- /dev/null +++ b/.changeset/harness-session-seed.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-harness': minor +--- + +Add `session.seed({ messages })`: it writes messages that exist already into an empty thread, so an app can bring its earlier conversations into the harness and the next turn reads them. A retry with the same messages is accepted and writes nothing. It refuses a thread with other messages (`not_empty`), a tool call with no result (`open_tool_call`), and a session that is not idle (`busy`). On a host with access, it needs the `send` action. diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 2da4f02fb5..78dffe81ba 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -25,6 +25,7 @@ import { createInputId, createMessageId, offeredInputRecord, + settledMessages, storedUser, } from './session/helpers' import { observedSession } from './session/observed-session' @@ -1503,24 +1504,6 @@ function keptRecordsOf(harness: AnyHarness) { return keep } -/** - * The messages of `messages` up to the last point where each tool call has - * its result. A running turn can have a call with no result yet: it does - * not move. - */ -function settledMessages(messages: ReadonlyArray) { - const open = new Set() - let end = 0 - messages.forEach((message, index) => { - for (const call of message.toolCalls ?? []) open.add(call.id) - if (message.role === 'tool' && message.toolCallId) { - open.delete(message.toolCallId) - } - if (open.size === 0) end = index + 1 - }) - return messages.slice(0, end) -} - /** One link of the index `metadata` of a transfer. */ interface TransferMetadataLink { transferId: string diff --git a/packages/ai-harness/src/session/handle.ts b/packages/ai-harness/src/session/handle.ts index 173763909d..fdef68c268 100644 --- a/packages/ai-harness/src/session/handle.ts +++ b/packages/ai-harness/src/session/handle.ts @@ -607,6 +607,8 @@ export function sessionHandle({ revert: (options: Parameters['revert']>[0]) => acting('send', (target) => target.revert(as(options))), unrevert: () => acting('send', (target) => target.unrevert()), + seed: (options: Parameters['seed']>[0]) => + acting('send', (target) => target.seed(options)), moveToolCallToBackground: ( options?: Parameters['moveToolCallToBackground']>[0], ) => diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 8310ffe3d9..3343ad9f94 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -89,6 +89,7 @@ type PublicSessionMember = | 'runCommand' | 'runTool' | 'schedule' + | 'seed' | 'send' | 'setPluginSetting' | 'settled' diff --git a/packages/ai-harness/src/session/helpers.ts b/packages/ai-harness/src/session/helpers.ts index 43fac0c9d7..b679ca3bec 100644 --- a/packages/ai-harness/src/session/helpers.ts +++ b/packages/ai-harness/src/session/helpers.ts @@ -1194,3 +1194,21 @@ type LinkedRun = PromiseLike & { abortController: AbortController isSettled: () => boolean } + +/** + * The messages of `messages` up to the last point where each tool call has + * its result. A running turn can have a call with no result yet: it does + * not move. + */ +export function settledMessages(messages: ReadonlyArray) { + const open = new Set() + let end = 0 + messages.forEach((message, index) => { + for (const call of message.toolCalls ?? []) open.add(call.id) + if (message.role === 'tool' && message.toolCallId) { + open.delete(message.toolCallId) + } + if (open.size === 0) end = index + 1 + }) + return messages.slice(0, end) +} diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 17be2cb44c..296bf6e13e 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -39,6 +39,7 @@ import { isOfferedInputRecord, scopeOf, loggedChildWaits, + settledMessages, snapshotFrom, storedUser, changeSettings, @@ -114,6 +115,16 @@ class AnswerRefused extends Error { * A layer of {@link HarnessSession}: config, thread settings, revert, * interrupts, media, and the snapshot. */ +/** + * What names `message` in a seed: its role and id, or the call id of a tool + * result. `undefined` for a message with neither. + */ +function seedKey(message: ModelMessage | undefined) { + const id = + message?.id ?? (message?.role === 'tool' ? message.toolCallId : undefined) + return id ? `${message?.role}:${id}` : undefined +} + export abstract class SessionState< THarness extends AnyHarness = AnyHarness, > extends SessionInputs { @@ -952,6 +963,58 @@ export abstract class SessionState< }) } + /** + * Start this empty thread with `messages` that exist already: a + * conversation of your app from before it used the harness, or context + * for a new thread. The transcript keeps them as they are, and the next + * turn reads them. Each message needs an `id`, except a tool result, which + * its `toolCallId` names. + * + * A thread whose transcript starts with these messages (the same ids and + * roles) has the seed already: the seed is accepted and writes nothing, so + * a retry is safe. Refused for a thread with other messages + * (`'not_empty'`), for a tool call with no result in `messages` + * (`'open_tool_call'`), and while the session is not idle or another host + * runs the thread (`'busy'`). Throws for a message with no id. + * + * @example + * ```ts + * await session.seed({ messages: await loadOldConversation(threadId) }) + * ``` + */ + async seed({ + messages, + }: { + messages: ReadonlyArray + }): Promise { + const keys = messages.map((message, index) => { + const key = seedKey(message) + if (key === undefined) { + throw new Error(`Message ${index} of the seed has no id.`) + } + return key + }) + const inputId = createInputId() + if (!(await this.ownsIdleThread())) { + return { inputId, status: 'rejected', reason: 'busy' } + } + return this.holding(async () => { + const { transcript } = this.writer.state + const isSeeded = keys.every( + (key, index) => seedKey(transcript[index]) === key, + ) + if (isSeeded) return { inputId, status: 'accepted' } + if (transcript.length > 0) { + return { inputId, status: 'rejected', reason: 'not_empty' } + } + if (settledMessages(messages).length < messages.length) { + return { inputId, status: 'rejected', reason: 'open_tool_call' } + } + await this.writer.commit({ messages: [...messages] }) + return { inputId, status: 'accepted' } + }) + } + /** * Erase the messages of the user `userId`, as `host.sessions.eraseUser` * says: log the erase, write a snapshot, drop the records before it, and diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts new file mode 100644 index 0000000000..26b075934d --- /dev/null +++ b/packages/ai-harness/tests/seed.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' +import { convertMessagesToModelMessages } from '@tanstack/ai' +import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' +import { + AccessDeniedError, + createHarnessHost, + defineHarness, + membersOnly, +} from '../src' +import { after, gate, messageTexts, mockAdapter, text } from './helpers' +import type { ModelMessage, UIMessage } from '@tanstack/ai' +import type { Reply } from './helpers' + +/** A conversation that an app kept before it used the harness. */ +const OLD: Array = [ + { id: 'old-1', role: 'user', content: 'Where is my order?' }, + { + id: 'old-2', + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call-old', + type: 'function', + function: { name: 'findOrder', arguments: '{"id":42}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call-old', content: 'Shipped.' }, + { id: 'old-4', role: 'assistant', content: 'Order 42 shipped yesterday.' }, +] + +const idsOf = (messages: ReadonlyArray) => + messages.map((message) => message.id) + +/** A log and metadata, so a second host can open the thread after a close. */ +function persistenceFor() { + const { metadata } = memoryPersistence().stores + return { stores: { log: memoryLogStore(), metadata } } +} + +async function open(replies: Array = [() => text('ok')]) { + const { adapter, calls } = mockAdapter(replies) + const persistence = persistenceFor() + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ name: 'test/seed', adapter }) + const session = await host.open(harness, { threadId: 't1' }) + return { session, calls, host, harness, persistence } +} + +describe('session.seed', () => { + it('starts an empty thread with the messages, and the next turn reads them', async () => { + const { session, calls, host } = await open([ + () => text('It arrives today.'), + ]) + + expect(await session.seed({ messages: OLD })).toMatchObject({ + status: 'accepted', + }) + await session.send('When does it arrive?') + + expect(messageTexts(calls[0])).toEqual([ + 'Where is my order?', + 'null', + 'Shipped.', + 'Order 42 shipped yesterday.', + 'When does it arrive?', + ]) + expect(calls[0].messages[1].toolCalls[0].id).toBe('call-old') + const transcript = await session.transcript() + expect(idsOf(transcript.slice(0, OLD.length))).toEqual(idsOf(OLD)) + expect(transcript).toHaveLength(OLD.length + 2) + await host.close() + }) + + it('keeps the seed in the log for the next host', async () => { + const { session, host, harness, persistence } = await open() + await session.seed({ messages: OLD }) + await host.close() + + const next = createHarnessHost({ persistence }) + const reopened = await next.open(harness, { threadId: 't1' }) + + expect(idsOf(await reopened.transcript())).toEqual(idsOf(OLD)) + await next.close() + }) + + it('accepts a retry and writes nothing, also after a turn', async () => { + const { session, host } = await open() + await session.seed({ messages: OLD }) + await session.send('Thanks.') + const before = await session.transcript() + + expect(await session.seed({ messages: OLD })).toMatchObject({ + status: 'accepted', + }) + expect(await session.transcript()).toEqual(before) + await host.close() + }) + + it('refuses a thread with other messages', async () => { + const { session, host } = await open() + await session.send('Hello.') + + expect(await session.seed({ messages: OLD })).toMatchObject({ + status: 'rejected', + reason: 'not_empty', + }) + expect(await session.transcript()).toHaveLength(2) + await host.close() + }) + + it('refuses a tool call with no result', async () => { + const { session, host } = await open() + + expect(await session.seed({ messages: OLD.slice(0, 2) })).toMatchObject({ + status: 'rejected', + reason: 'open_tool_call', + }) + expect(await session.transcript()).toEqual([]) + await host.close() + }) + + it('refuses while a turn runs', async () => { + const release = gate() + const { session, host } = await open([after(release.opened, 'Done.')]) + const turn = session.send('Hello.') + await turn.receipt + + expect(await session.seed({ messages: OLD })).toMatchObject({ + status: 'rejected', + reason: 'busy', + }) + release.open() + await turn + await host.close() + }) + + it('throws for a message with no id', async () => { + const { session, host } = await open() + + await expect( + session.seed({ messages: [{ role: 'user', content: 'Hi.' }] }), + ).rejects.toThrow(/Message 0 of the seed has no id/) + await host.close() + }) + + it('takes the model messages of UI messages, and a retry of them', async () => { + const { session, host } = await open() + const chat: Array = [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', content: 'Hi.' }] }, + { + id: 'a-1', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-1', + name: 'lookUp', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"found":true}', + state: 'complete', + }, + { type: 'text', content: 'Found it.' }, + ], + }, + ] + const messages = convertMessagesToModelMessages(chat) + + for (let attempt = 0; attempt < 2; attempt++) { + expect(await session.seed({ messages })).toMatchObject({ + status: 'accepted', + }) + } + expect(await session.transcript()).toHaveLength(messages.length) + await host.close() + }) + + it('needs the send action on a host with access', async () => { + interface User { + id: string + tenantId: string + } + const olga: User = { id: 'olga', tenantId: 'acme' } + const vic: User = { id: 'vic', tenantId: 'acme' } + const host = createHarnessHost({ + persistence: memoryPersistence(), + access: { getUser: (): User | null => null, rules: membersOnly() }, + }) + const harness = defineHarness({ + name: 'test/seed-access', + adapter: mockAdapter([]).adapter, + }) + const owner = await host.open(harness, { threadId: 't1', user: olga }) + await host.sessions.share({ + threadId: 't1', + userId: vic.id, + role: 'viewer', + user: olga, + }) + const viewer = await host.open(harness, { threadId: 't1', user: vic }) + + await expect(viewer.seed({ messages: OLD })).rejects.toBeInstanceOf( + AccessDeniedError, + ) + expect(await owner.seed({ messages: OLD })).toMatchObject({ + status: 'accepted', + }) + await host.close() + }) +}) From 2064286591ae1d4f9c92a1187af5eddd2b453582 Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Fri, 23 Oct 2026 17:40:30 +0200 Subject: [PATCH 2/7] docs(ai-harness): start a session from an existing conversation Co-Authored-By: Claude Opus 5.5 --- docs/config.json | 2 +- docs/harness/sessions.md | 31 +++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/config.json b/docs/config.json index 359b32483f..6410601c58 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1140,7 +1140,7 @@ "label": "List, rename, and fork sessions", "to": "harness/sessions", "addedAt": "2026-10-13", - "updatedAt": "2026-10-22" + "updatedAt": "2026-10-23" }, { "label": "Lifecycle and retention", diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index 96fdf28658..d978f33c46 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -2,7 +2,7 @@ title: List, rename, and fork sessions id: harness-sessions order: 4 -description: "Show a sidebar of past chats with titles and a live status. Rename, archive, remove, fork, and revert a chat, and keep typed app data on each one." +description: "Show a sidebar of past chats with titles and a live status. Rename, archive, remove, fork, revert, and seed a chat, and keep typed app data on each one." keywords: - tanstack ai - harness @@ -155,6 +155,33 @@ await session.unrevert() - Each change sends a `harness.revert` event. Read the transcript again when it arrives. - `revert` and `unrevert` refuse with `busy` when the session is not idle, or when another host runs the thread. `revert` refuses with `unknown_message` for an id that is not in the transcript. +## Start a session from an existing conversation + +`session.seed` writes messages that exist already into an empty thread. The next turn reads them. Use it for the chats that your app kept before it used the harness. A turn reads only the session log, and `POST run` takes only the last user message of the request. + +```ts group=harness-sessions +import { convertMessagesToModelMessages } from '@tanstack/ai' +import type { UIMessage } from '@tanstack/ai' + +declare function loadOldChat(chatId: string): Promise> + +const imported = await host.open(assistant, { + threadId: 'chat-old-7', + user: { id: 'ada' }, +}) +const receipt = await imported.seed({ + messages: convertMessagesToModelMessages(await loadOldChat('chat-old-7')), +}) +if (receipt.status === 'rejected') throw new Error(receipt.reason) +await imported.send('Where did we leave off?') +``` + +- `messages` are model messages. Each one needs an `id`, and a tool result needs its `toolCallId`. A message with neither throws. +- The transcript keeps the messages as they are, with their ids. +- A retry is safe. When the transcript starts with the same messages (the same roles and ids), `seed` accepts and writes nothing. +- `seed` refuses with `not_empty` for a thread with other messages, `open_tool_call` for a tool call with no result, and `busy` while a turn runs or another host runs the thread. With host `access`, it needs the `send` action. +- The seeded messages belong to no input, so `host.sessions.eraseUser` does not find them. A media part keeps its source, and the host does not copy it into the media store of the thread. + ## Rename and remove a session ```ts group=harness-sessions @@ -327,7 +354,7 @@ stopListening() ## What you have now - A sidebar of past chats, newest first, with a title for each. -- Rename, archive, remove, fork, and revert from your server code. +- Rename, archive, remove, fork, revert, and seed from your server code. - Typed app data on each session, such as the pinned files of a chat. - A status for each chat that stays current. - A listener that runs your code when a session sends an event. From a85f79003517c178722a04771f44e311d2548abf Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Sat, 24 Oct 2026 10:06:15 +0200 Subject: [PATCH 3/7] fix(ai-harness): seeds run one at a time and refuse empty or orphaned input Two seeds of an empty thread could both pass the empty check and both write. Seeds now run on a per-session chain, so the second one sees the first seed and gets `not_empty` or an `accepted` retry. An empty seed matched any transcript and was accepted on a thread with messages. It is now accepted in an empty thread only, and `not_empty` otherwise. A tool result with no `toolCallId`, or with no open call before it, passed. It is now refused with `unknown_tool_call`. Co-Authored-By: Claude Opus 5.5 --- .changeset/harness-session-seed.md | 2 +- docs/harness/sessions.md | 10 +++- packages/ai-harness/src/session/state.ts | 63 ++++++++++++++++++------ packages/ai-harness/tests/seed.test.ts | 59 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 18 deletions(-) diff --git a/.changeset/harness-session-seed.md b/.changeset/harness-session-seed.md index 32d6eaa113..d3233671b9 100644 --- a/.changeset/harness-session-seed.md +++ b/.changeset/harness-session-seed.md @@ -2,4 +2,4 @@ '@tanstack/ai-harness': minor --- -Add `session.seed({ messages })`: it writes messages that exist already into an empty thread, so an app can bring its earlier conversations into the harness and the next turn reads them. A retry with the same messages is accepted and writes nothing. It refuses a thread with other messages (`not_empty`), a tool call with no result (`open_tool_call`), and a session that is not idle (`busy`). On a host with access, it needs the `send` action. +Add `session.seed({ messages })`: it writes messages that exist already into an empty thread, so an app can bring its earlier conversations into the harness and the next turn reads them. A retry with the same messages is accepted and writes nothing. It refuses a thread with other messages (`not_empty`), a tool call with no result (`open_tool_call`), a tool result with no call before it (`unknown_tool_call`), and a session that is not idle (`busy`). On a host with access, it needs the `send` action. diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index d978f33c46..47f7b9c692 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -178,8 +178,14 @@ await imported.send('Where did we leave off?') - `messages` are model messages. Each one needs an `id`, and a tool result needs its `toolCallId`. A message with neither throws. - The transcript keeps the messages as they are, with their ids. -- A retry is safe. When the transcript starts with the same messages (the same roles and ids), `seed` accepts and writes nothing. -- `seed` refuses with `not_empty` for a thread with other messages, `open_tool_call` for a tool call with no result, and `busy` while a turn runs or another host runs the thread. With host `access`, it needs the `send` action. +- A retry is safe. When the transcript starts with the same messages (the same roles and ids), `seed` accepts and writes nothing. Seeds run one at a time, so when two seeds of an empty thread start at once, only one writes. +- An empty `messages` is accepted in an empty thread only. It writes nothing. +- `seed` refuses with: + - `not_empty` for a thread with other messages. + - `open_tool_call` for a tool call with no result. + - `unknown_tool_call` for a tool result with no call before it in `messages`. + - `busy` while a turn runs or another host runs the thread. +- With host `access`, `seed` needs the `send` action. - The seeded messages belong to no input, so `host.sessions.eraseUser` does not find them. A media part keeps its source, and the host does not copy it into the media store of the thread. ## Rename and remove a session diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 296bf6e13e..1b285bd35b 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -39,7 +39,6 @@ import { isOfferedInputRecord, scopeOf, loggedChildWaits, - settledMessages, snapshotFrom, storedUser, changeSettings, @@ -111,10 +110,6 @@ class AnswerRefused extends Error { } } -/** - * A layer of {@link HarnessSession}: config, thread settings, revert, - * interrupts, media, and the snapshot. - */ /** * What names `message` in a seed: its role and id, or the call id of a tool * result. `undefined` for a message with neither. @@ -125,6 +120,28 @@ function seedKey(message: ModelMessage | undefined) { return id ? `${message?.role}:${id}` : undefined } +/** + * Why the tool calls of `messages` cannot start a thread: a tool result + * that answers no open call before it (`'unknown_tool_call'`), or a call + * with no result (`'open_tool_call'`). `undefined` when each call has its + * result. + */ +function seedToolFault(messages: ReadonlyArray) { + const open = new Set() + for (const message of messages) { + if (message.role === 'tool') { + const callId = message.toolCallId + if (!callId || !open.delete(callId)) return 'unknown_tool_call' + } + for (const call of message.toolCalls ?? []) open.add(call.id) + } + return open.size > 0 ? 'open_tool_call' : undefined +} + +/** + * A layer of {@link HarnessSession}: config, thread settings, revert, + * interrupts, media, and the snapshot. + */ export abstract class SessionState< THarness extends AnyHarness = AnyHarness, > extends SessionInputs { @@ -133,6 +150,8 @@ export abstract class SessionState< * on when the log ends its wait (see `afterWaitEnd`). */ protected readonly waitEnds = new Map void>() + /** The last `seed()`. The calls run one at a time. */ + protected seeds: Promise = Promise.resolve() /** The secret answers to `step.ask` waits that this host kept, by wait id. */ protected readonly keptAnswers = new Map() /** @@ -972,10 +991,13 @@ export abstract class SessionState< * * A thread whose transcript starts with these messages (the same ids and * roles) has the seed already: the seed is accepted and writes nothing, so - * a retry is safe. Refused for a thread with other messages - * (`'not_empty'`), for a tool call with no result in `messages` - * (`'open_tool_call'`), and while the session is not idle or another host - * runs the thread (`'busy'`). Throws for a message with no id. + * a retry is safe. An empty `messages` is accepted in an empty thread + * only. Seeds run one at a time, so of two seeds of an empty thread one + * writes. Refused for a thread with other messages (`'not_empty'`), for a + * tool call with no result in `messages` (`'open_tool_call'`), for a tool + * result with no call before it in `messages` (`'unknown_tool_call'`), and + * while the session is not idle or another host runs the thread + * (`'busy'`). Throws for a message with no id. * * @example * ```ts @@ -994,22 +1016,33 @@ export abstract class SessionState< } return key }) + // One at a time: two seeds of an empty thread must not both write. + const run = this.seeds.then(() => this.runSeed(messages, keys)) + this.seeds = run.catch(() => {}) + return run + } + + protected async runSeed( + messages: ReadonlyArray, + keys: ReadonlyArray, + ): Promise { const inputId = createInputId() if (!(await this.ownsIdleThread())) { return { inputId, status: 'rejected', reason: 'busy' } } return this.holding(async () => { const { transcript } = this.writer.state - const isSeeded = keys.every( - (key, index) => seedKey(transcript[index]) === key, - ) + // An empty seed is in an empty thread only. + const isSeeded = + keys.length === 0 + ? transcript.length === 0 + : keys.every((key, index) => seedKey(transcript[index]) === key) if (isSeeded) return { inputId, status: 'accepted' } if (transcript.length > 0) { return { inputId, status: 'rejected', reason: 'not_empty' } } - if (settledMessages(messages).length < messages.length) { - return { inputId, status: 'rejected', reason: 'open_tool_call' } - } + const fault = seedToolFault(messages) + if (fault) return { inputId, status: 'rejected', reason: fault } await this.writer.commit({ messages: [...messages] }) return { inputId, status: 'accepted' } }) diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts index 26b075934d..1e436152a7 100644 --- a/packages/ai-harness/tests/seed.test.ts +++ b/packages/ai-harness/tests/seed.test.ts @@ -121,6 +121,65 @@ describe('session.seed', () => { await host.close() }) + it('writes one of two seeds that start at once', async () => { + const { session, host } = await open() + const other: Array = [ + { id: 'other-1', role: 'user', content: 'Hello.' }, + ] + + const receipts = await Promise.all([ + session.seed({ messages: OLD }), + session.seed({ messages: other }), + session.seed({ messages: OLD }), + ]) + + expect(receipts.map((receipt) => receipt.status)).toEqual([ + 'accepted', + 'rejected', + 'accepted', + ]) + expect(receipts[1]).toMatchObject({ reason: 'not_empty' }) + expect(idsOf(await session.transcript())).toEqual(idsOf(OLD)) + await host.close() + }) + + it('refuses an empty seed of a thread with messages, and accepts it in an empty thread', async () => { + const { session, host } = await open() + + expect(await session.seed({ messages: [] })).toMatchObject({ + status: 'accepted', + }) + expect(await session.transcript()).toEqual([]) + await session.seed({ messages: OLD }) + expect(await session.seed({ messages: [] })).toMatchObject({ + status: 'rejected', + reason: 'not_empty', + }) + await host.close() + }) + + it('refuses a tool result with no call before it', async () => { + const { session, host } = await open() + const orphan: Array = [ + ...OLD.slice(0, 1), + { role: 'tool', toolCallId: 'call-none', content: 'Shipped.' }, + ] + const unnamed: Array = [ + ...OLD.slice(0, 1), + { id: 'tool-1', role: 'tool', content: 'Shipped.' }, + ] + const answeredTwice = [...OLD.slice(0, 3), ...OLD.slice(2, 3)] + + for (const messages of [orphan, unnamed, answeredTwice]) { + expect(await session.seed({ messages })).toMatchObject({ + status: 'rejected', + reason: 'unknown_tool_call', + }) + } + expect(await session.transcript()).toEqual([]) + await host.close() + }) + it('refuses while a turn runs', async () => { const release = gate() const { session, host } = await open([after(release.opened, 'Done.')]) From cbceba254d3c007cdfab60f7fc0df90a013a4f99 Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Sun, 25 Oct 2026 10:05:55 +0100 Subject: [PATCH 4/7] fix(ai-harness): reject a seed that repeats a tool-call id A call that repeats the id of an open call left one of the two with no result, yet a single tool result cleared the id. seed now refuses it as open_tool_call. Co-Authored-By: Claude Opus 5.5 --- docs/harness/sessions.md | 2 +- packages/ai-harness/src/session/state.ts | 19 ++++++++++------- packages/ai-harness/tests/seed.test.ts | 26 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index 47f7b9c692..23a0076a82 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -182,7 +182,7 @@ await imported.send('Where did we leave off?') - An empty `messages` is accepted in an empty thread only. It writes nothing. - `seed` refuses with: - `not_empty` for a thread with other messages. - - `open_tool_call` for a tool call with no result. + - `open_tool_call` for a tool call with no result, such as a call that repeats the id of a call that has no result yet. - `unknown_tool_call` for a tool result with no call before it in `messages`. - `busy` while a turn runs or another host runs the thread. - With host `access`, `seed` needs the `send` action. diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 1b285bd35b..3fd9a76055 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -123,8 +123,9 @@ function seedKey(message: ModelMessage | undefined) { /** * Why the tool calls of `messages` cannot start a thread: a tool result * that answers no open call before it (`'unknown_tool_call'`), or a call - * with no result (`'open_tool_call'`). `undefined` when each call has its - * result. + * with no result (`'open_tool_call'`). A call that repeats the id of an open + * call leaves one of the two with no result. `undefined` when each call has + * its result. */ function seedToolFault(messages: ReadonlyArray) { const open = new Set() @@ -133,7 +134,10 @@ function seedToolFault(messages: ReadonlyArray) { const callId = message.toolCallId if (!callId || !open.delete(callId)) return 'unknown_tool_call' } - for (const call of message.toolCalls ?? []) open.add(call.id) + for (const call of message.toolCalls ?? []) { + if (open.has(call.id)) return 'open_tool_call' + open.add(call.id) + } } return open.size > 0 ? 'open_tool_call' : undefined } @@ -994,10 +998,11 @@ export abstract class SessionState< * a retry is safe. An empty `messages` is accepted in an empty thread * only. Seeds run one at a time, so of two seeds of an empty thread one * writes. Refused for a thread with other messages (`'not_empty'`), for a - * tool call with no result in `messages` (`'open_tool_call'`), for a tool - * result with no call before it in `messages` (`'unknown_tool_call'`), and - * while the session is not idle or another host runs the thread - * (`'busy'`). Throws for a message with no id. + * tool call with no result in `messages`, such as one that repeats the id + * of an open call (`'open_tool_call'`), for a tool result with no call + * before it in `messages` (`'unknown_tool_call'`), and while the session is + * not idle or another host runs the thread (`'busy'`). Throws for a message + * with no id. * * @example * ```ts diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts index 1e436152a7..97cbe3cd40 100644 --- a/packages/ai-harness/tests/seed.test.ts +++ b/packages/ai-harness/tests/seed.test.ts @@ -121,6 +121,32 @@ describe('session.seed', () => { await host.close() }) + it('refuses a tool call that repeats the id of an open call', async () => { + const { session, host } = await open() + const call = { + id: 'call-old', + type: 'function' as const, + function: { name: 'findOrder', arguments: '{"id":42}' }, + } + const repeated: Array = [ + ...OLD.slice(0, 1), + { + id: 'old-2', + role: 'assistant', + content: null, + toolCalls: [call, call], + }, + ...OLD.slice(2), + ] + + expect(await session.seed({ messages: repeated })).toMatchObject({ + status: 'rejected', + reason: 'open_tool_call', + }) + expect(await session.transcript()).toEqual([]) + await host.close() + }) + it('writes one of two seeds that start at once', async () => { const { session, host } = await open() const other: Array = [ From a3c6df168b5a49d46fa4f67f17d614565040c58d Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Mon, 26 Oct 2026 10:09:38 +0100 Subject: [PATCH 5/7] fix(ai-harness): reject a seed that repeats a message id revert resolves a message id in the transcript, so a seed with two messages of one id made a later revert land on the wrong one. seed now refuses it as duplicate_message. An assistant message may repeat the id of the assistant message before it across its tool results: that is how convertMessagesToModelMessages splits one UI message, and the docs tell apps to seed its output. revert now resolves an id to its last message, so reverting to such a UI message keeps all of it. The docs also say that a tool result may leave out its id when it has its toolCallId. Co-Authored-By: Claude Opus 5.5 --- .changeset/harness-session-seed.md | 2 +- docs/harness/sessions.md | 5 +- packages/ai-harness/src/log.ts | 5 +- packages/ai-harness/src/session/state.ts | 46 ++++++++++-- packages/ai-harness/tests/seed.test.ts | 93 ++++++++++++++++++------ 5 files changed, 116 insertions(+), 35 deletions(-) diff --git a/.changeset/harness-session-seed.md b/.changeset/harness-session-seed.md index d3233671b9..5b70631e62 100644 --- a/.changeset/harness-session-seed.md +++ b/.changeset/harness-session-seed.md @@ -2,4 +2,4 @@ '@tanstack/ai-harness': minor --- -Add `session.seed({ messages })`: it writes messages that exist already into an empty thread, so an app can bring its earlier conversations into the harness and the next turn reads them. A retry with the same messages is accepted and writes nothing. It refuses a thread with other messages (`not_empty`), a tool call with no result (`open_tool_call`), a tool result with no call before it (`unknown_tool_call`), and a session that is not idle (`busy`). On a host with access, it needs the `send` action. +Add `session.seed({ messages })`: it writes messages that exist already into an empty thread, so an app can bring its earlier conversations into the harness and the next turn reads them. A retry with the same messages is accepted and writes nothing. It refuses a thread with other messages (`not_empty`), two messages with the same id (`duplicate_message`, except the parts of one UI message split at its tool results), a tool call with no result (`open_tool_call`), a tool result with no call before it (`unknown_tool_call`), and a session that is not idle (`busy`). On a host with access, it needs the `send` action. diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index 23a0076a82..cde3d32b08 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -149,7 +149,7 @@ await session.revert({ messageId: question.id }) await session.unrevert() ``` -- While a revert stands, `transcript()` hides the messages after that message. The session keeps them. +- While a revert stands, `transcript()` hides the messages after that message. The session keeps them. When several messages share the id, such as the parts of one seeded UI message, the revert goes to the last one, so the whole UI message stays. - The next turn drops the hidden messages for good. A steer, a follow-up, and `continueWithoutMessage` are turns too. - The session saves the revert in the log. A restart keeps it. - Each change sends a `harness.revert` event. Read the transcript again when it arrives. @@ -176,12 +176,13 @@ if (receipt.status === 'rejected') throw new Error(receipt.reason) await imported.send('Where did we leave off?') ``` -- `messages` are model messages. Each one needs an `id`, and a tool result needs its `toolCallId`. A message with neither throws. +- `messages` are model messages. Each one needs an `id`. A tool result may leave out `id` when it has its `toolCallId`. A message with neither throws. - The transcript keeps the messages as they are, with their ids. - A retry is safe. When the transcript starts with the same messages (the same roles and ids), `seed` accepts and writes nothing. Seeds run one at a time, so when two seeds of an empty thread start at once, only one writes. - An empty `messages` is accepted in an empty thread only. It writes nothing. - `seed` refuses with: - `not_empty` for a thread with other messages. + - `duplicate_message` for two messages with the same `id`, whatever their roles. An assistant message may repeat the id of the assistant message before it when only tool results sit between them. `convertMessagesToModelMessages` splits one UI message at its tool results that way. - `open_tool_call` for a tool call with no result, such as a call that repeats the id of a call that has no result yet. - `unknown_tool_call` for a tool result with no call before it in `messages`. - `busy` while a turn runs or another host runs the thread. diff --git a/packages/ai-harness/src/log.ts b/packages/ai-harness/src/log.ts index 14f15fc9bd..63ca84c6ab 100644 --- a/packages/ai-harness/src/log.ts +++ b/packages/ai-harness/src/log.ts @@ -185,7 +185,8 @@ export function revertOf(value: unknown): RevertState | undefined { /** * The messages that the transcript shows: while `revert` stands, the - * messages after its message are hidden. + * messages after its message are hidden. Of messages that share its id, it + * is the last one. */ export function visibleMessages( messages: ReadonlyArray, @@ -194,7 +195,7 @@ export function visibleMessages( const at = revert === undefined ? -1 - : messages.findIndex((message) => message.id === revert.messageId) + : messages.findLastIndex((message) => message.id === revert.messageId) return at < 0 ? [...messages] : messages.slice(0, at + 1) } diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 3fd9a76055..5c21a20073 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -120,6 +120,30 @@ function seedKey(message: ModelMessage | undefined) { return id ? `${message?.role}:${id}` : undefined } +/** + * Whether two of `messages` share an `id`, whatever their roles. An + * assistant message may repeat the id of the assistant message before it, + * with only tool results between them: `convertMessagesToModelMessages` + * splits one UI message at its tool results that way. + */ +function repeatsMessageId(messages: ReadonlyArray) { + const ids = new Set() + let previous: ModelMessage | undefined + for (const message of messages) { + const { id } = message + if (id !== undefined) { + const continues = + message.role === 'assistant' && + previous?.role === 'assistant' && + previous.id === id + if (ids.has(id) && !continues) return true + ids.add(id) + } + if (message.role !== 'tool') previous = message + } + return false +} + /** * Why the tool calls of `messages` cannot start a thread: a tool result * that answers no open call before it (`'unknown_tool_call'`), or a call @@ -912,17 +936,21 @@ export abstract class SessionState< | undefined } - /** The index of the revert message in `messages`, or -1. */ + /** + * The index of the revert message in `messages`, or -1. Of messages that + * share the id, the last one: see `revert`. + */ protected revertedAt(messages: ReadonlyArray) { const id = this.reverted?.messageId return id === undefined ? -1 - : messages.findIndex((message) => message.id === id) + : messages.findLastIndex((message) => message.id === id) } /** * Go back to the message `messageId`. The transcript hides the messages - * after it. With the snapshots plugin, the files that their tool calls + * after it. Of messages that share the id, such as the parts of one UI + * message that a seed split at its tool results, it is the last one. With the snapshots plugin, the files that their tool calls * changed go back too. `unrevert()` undoes it. The next turn drops the * hidden messages for good. The revert state is saved, so it survives a * restart. Refused while the session is not idle, or another host runs @@ -952,7 +980,7 @@ export abstract class SessionState< // The reader picks a message of the transcript, also one that a // compaction took out of the model context. const messages = [...this.writer.state.transcript] - const at = messages.findIndex((message) => message.id === messageId) + const at = messages.findLastIndex((message) => message.id === messageId) if (at < 0) return { inputId, status: 'rejected', reason: 'unknown_message' } // A revert that stands ends first, so its files come back. @@ -997,8 +1025,11 @@ export abstract class SessionState< * roles) has the seed already: the seed is accepted and writes nothing, so * a retry is safe. An empty `messages` is accepted in an empty thread * only. Seeds run one at a time, so of two seeds of an empty thread one - * writes. Refused for a thread with other messages (`'not_empty'`), for a - * tool call with no result in `messages`, such as one that repeats the id + * writes. Refused for a thread with other messages (`'not_empty'`), for + * two messages with the same `id`, whatever their roles + * (`'duplicate_message'`), except an assistant message that continues the + * one before it across its tool results, as + * `convertMessagesToModelMessages` writes it, for a tool call with no result in `messages`, such as one that repeats the id * of an open call (`'open_tool_call'`), for a tool result with no call * before it in `messages` (`'unknown_tool_call'`), and while the session is * not idle or another host runs the thread (`'busy'`). Throws for a message @@ -1046,6 +1077,9 @@ export abstract class SessionState< if (transcript.length > 0) { return { inputId, status: 'rejected', reason: 'not_empty' } } + if (repeatsMessageId(messages)) { + return { inputId, status: 'rejected', reason: 'duplicate_message' } + } const fault = seedToolFault(messages) if (fault) return { inputId, status: 'rejected', reason: fault } await this.writer.commit({ messages: [...messages] }) diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts index 97cbe3cd40..ae34834fe2 100644 --- a/packages/ai-harness/tests/seed.test.ts +++ b/packages/ai-harness/tests/seed.test.ts @@ -30,6 +30,34 @@ const OLD: Array = [ { id: 'old-4', role: 'assistant', content: 'Order 42 shipped yesterday.' }, ] +/** + * A chat of UI messages. The converter splits `a-1` at its tool result into + * two assistant messages with that id. + */ +const CHAT: Array = [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', content: 'Hi.' }] }, + { + id: 'a-1', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-1', + name: 'lookUp', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"found":true}', + state: 'complete', + }, + { type: 'text', content: 'Found it.' }, + ], + }, +] + const idsOf = (messages: ReadonlyArray) => messages.map((message) => message.id) @@ -147,6 +175,33 @@ describe('session.seed', () => { await host.close() }) + it('refuses two messages with the same id, whatever their roles', async () => { + const { session, host } = await open() + const sameRole: Array = [ + ...OLD, + { id: 'old-1', role: 'user', content: 'And my refund?' }, + ] + const otherRole: Array = [ + ...OLD.slice(0, 3), + { id: 'old-1', role: 'assistant', content: 'Order 42 shipped.' }, + ] + // a-1 again after a user message is another message, not a split one. + const afterUser: Array = [ + ...convertMessagesToModelMessages(CHAT), + { id: 'u-2', role: 'user', content: 'Thanks.' }, + { id: 'a-1', role: 'assistant', content: 'You are welcome.' }, + ] + + for (const messages of [sameRole, otherRole, afterUser]) { + expect(await session.seed({ messages })).toMatchObject({ + status: 'rejected', + reason: 'duplicate_message', + }) + } + expect(await session.transcript()).toEqual([]) + await host.close() + }) + it('writes one of two seeds that start at once', async () => { const { session, host } = await open() const other: Array = [ @@ -232,30 +287,7 @@ describe('session.seed', () => { it('takes the model messages of UI messages, and a retry of them', async () => { const { session, host } = await open() - const chat: Array = [ - { id: 'u-1', role: 'user', parts: [{ type: 'text', content: 'Hi.' }] }, - { - id: 'a-1', - role: 'assistant', - parts: [ - { - type: 'tool-call', - id: 'call-1', - name: 'lookUp', - arguments: '{}', - state: 'input-complete', - }, - { - type: 'tool-result', - toolCallId: 'call-1', - content: '{"found":true}', - state: 'complete', - }, - { type: 'text', content: 'Found it.' }, - ], - }, - ] - const messages = convertMessagesToModelMessages(chat) + const messages = convertMessagesToModelMessages(CHAT) for (let attempt = 0; attempt < 2; attempt++) { expect(await session.seed({ messages })).toMatchObject({ @@ -266,6 +298,19 @@ describe('session.seed', () => { await host.close() }) + it('reverts to a split UI message as a whole', async () => { + const { session, host } = await open() + const messages = convertMessagesToModelMessages(CHAT) + await session.seed({ messages }) + await session.send('Thanks.') + + expect(await session.revert({ messageId: 'a-1' })).toMatchObject({ + status: 'accepted', + }) + expect(await session.transcript()).toEqual(messages) + await host.close() + }) + it('needs the send action on a host with access', async () => { interface User { id: string From 948d90ffd054b774c6f2cb096038eadb84c63157 Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Mon, 26 Oct 2026 10:59:35 +0100 Subject: [PATCH 6/7] fix(ai-harness): a fork through a split UI message keeps all of it A seeded UI message split at its tool results leaves several messages with one id. A fork `at`/`through` that id stopped at the first piece and lost the tool result and the rest of the answer. It now copies through the last message of that id, as revert does. Co-Authored-By: Claude Opus 5.5 --- docs/harness/sessions.md | 2 +- packages/ai-harness/src/host.ts | 4 +++- packages/ai-harness/src/session/state.ts | 16 ++++++++-------- packages/ai-harness/tests/seed.test.ts | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index cde3d32b08..bb27df8700 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -127,7 +127,7 @@ const fork = await host.open(assistant, { threadId: forked.threadId }) await fork.send('Plan a trip to Porto.') ``` -- `at: { through: id }` copies the messages up to and including `id`. +- `at: { through: id }` copies the messages up to and including `id`. When several messages share the id, such as the parts of one seeded UI message, it copies through the last one. - `at: { before: id }` copies the messages before `id`. A fork before the first message has no messages, but it keeps the settings. - The host picks the new thread id. The new entry has the title of the old one plus ` (fork)`. A thread without a title gives a fork without a title. - With a `user` and host `access`, the user who forks owns the fork. `members` picks who else is in it. See [Fork a shared thread](./shared-threads#6-fork-a-shared-thread). Without access and without `members`, the fork keeps the owner and the members of the old thread. diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 78dffe81ba..0f06eb13dd 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -2785,7 +2785,9 @@ export function createHarnessHost( ? transcript.length : at === null ? 0 - : transcript.findIndex((message) => message.id === at) + 1 + : // Of messages that share the id, such as the parts of one + // seeded UI message, the last one: see `revert`. + transcript.findLastIndex((message) => message.id === at) + 1 if (typeof at === 'string' && end === 0) { throw new Error( `Thread ${JSON.stringify(threadId)} has no message ${JSON.stringify(at)}.`, diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 5c21a20073..cec0ee26f6 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -950,14 +950,14 @@ export abstract class SessionState< /** * Go back to the message `messageId`. The transcript hides the messages * after it. Of messages that share the id, such as the parts of one UI - * message that a seed split at its tool results, it is the last one. With the snapshots plugin, the files that their tool calls - * changed go back too. `unrevert()` undoes it. The next turn drops the - * hidden messages for good. The revert state is saved, so it survives a - * restart. Refused while the session is not idle, or another host runs - * the thread (`'busy'`), and for a message that is not in the transcript - * (`'unknown_message'`). `user` is who reverts. Default: the - * user that opened the session. The `harness.revert` record keeps - * it. A handle of `host.open` checks that it is the user of the + * message that a seed split at its tool results, it is the last one. With + * the snapshots plugin, the files that their tool calls changed go back too. + * `unrevert()` undoes it. The next turn drops the hidden messages for good. + * The revert state is saved, so it survives a restart. Refused while the + * session is not idle, or another host runs the thread (`'busy'`), and for a + * message that is not in the transcript (`'unknown_message'`). `user` is who + * reverts. Default: the user that opened the session. The `harness.revert` + * record keeps it. A handle of `host.open` checks that it is the user of the * handle. * * @example diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts index ae34834fe2..15520172f9 100644 --- a/packages/ai-harness/tests/seed.test.ts +++ b/packages/ai-harness/tests/seed.test.ts @@ -311,6 +311,22 @@ describe('session.seed', () => { await host.close() }) + it('forks through a split UI message as a whole', async () => { + const { session, host, harness } = await open() + const messages = convertMessagesToModelMessages(CHAT) + await session.seed({ messages }) + await session.send('Thanks.') + + const fork = await host.fork(harness, { + threadId: 't1', + newThreadId: 't2', + at: 'a-1', + }) + + expect(await fork.transcript()).toEqual(messages) + await host.close() + }) + it('needs the send action on a host with access', async () => { interface User { id: string From bc976d058922b48d35a99864a98e2e9dc90fe565 Mon Sep 17 00:00:00 2001 From: Alexandre Joly Date: Tue, 27 Oct 2026 10:05:07 +0100 Subject: [PATCH 7/7] fix(ai-harness): a fork before a message cuts before its first piece host.sessions.fork turned `{ before: id }` into the id of the message just before it, and forkInto copies through the last message of an id. When a tool result with its own id sits between two pieces of a seeded UI message, a fork before the tool result copied the tool result and the rest of the answer. forkInto now takes `{ before: id }` and cuts at the first message of that id, while an id still copies through the last one. Co-Authored-By: Claude Opus 5.5 --- packages/ai-harness/src/host.ts | 70 ++++++++++---------------- packages/ai-harness/tests/seed.test.ts | 18 +++++++ 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 0f06eb13dd..7d6b217310 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -2762,7 +2762,15 @@ export function createHarnessHost( */ const forkInto = async ( harness: THarness, - { threadId, newThreadId, at, user }: ForkSessionOptions, + { + threadId, + newThreadId, + at, + user, + }: Omit, 'at'> & { + // `{ before: id }` is the point of `host.sessions.fork`. + at?: string | null | { before: string } + }, ) => { if (newThreadId === threadId) { throw new Error('A fork needs a new thread: newThreadId is threadId.') @@ -2780,17 +2788,21 @@ export function createHarnessHost( // become the owner of a source with no owner. const source = await openSession(harness, { threadId }, tenantId) const transcript = await source.transcript() + // Of messages that share the id, such as the parts of one seeded UI + // message, `before` cuts before the first one, and an id copies + // through the last one: see `revert`. const end = at === undefined ? transcript.length : at === null ? 0 - : // Of messages that share the id, such as the parts of one - // seeded UI message, the last one: see `revert`. - transcript.findLastIndex((message) => message.id === at) + 1 - if (typeof at === 'string' && end === 0) { + : typeof at === 'string' + ? transcript.findLastIndex((message) => message.id === at) + 1 + : transcript.findIndex((message) => message.id === at.before) + if (at && (typeof at === 'string' ? end === 0 : end === -1)) { + const id = typeof at === 'string' ? at : at.before throw new Error( - `Thread ${JSON.stringify(threadId)} has no message ${JSON.stringify(at)}.`, + `Thread ${JSON.stringify(threadId)} has no message ${JSON.stringify(id)}.`, ) } // The new thread is a new root, so its log is its own. @@ -3506,44 +3518,14 @@ export function createHarnessHost( user, }) checkHarness(source, harness) - // `host.fork` takes the last message to copy, so `before` becomes - // the message just before it, or `null` before the first one. - let last: string | null = 'through' in at ? at.through : null - // The source stays open from the read of `before` to the fork. - const sourceKey = sessionKey(harness.name, tenantId, threadId) - hold(sourceKey) - let forked: SessionImpl - try { - if ('before' in at) { - // With no user, as `host.fork` opens the source. - const opened = await openSession(harness, { threadId }, tenantId) - const transcript = await opened.transcript() - const position = transcript.findIndex(({ id }) => id === at.before) - if (position === -1) { - throw new Error( - `The transcript has no message with id ${at.before}.`, - ) - } - if (position > 0) { - const previous = transcript[position - 1]?.id - if (!previous) { - throw new Error(`The message before ${at.before} has no id.`) - } - last = previous - } - } - const { fork, key } = await forkInto(harness, { - threadId, - newThreadId: `thread-${crypto.randomUUID()}`, - at: last, - ...(user ? { user } : {}), - }) - forked = fork - // No handle takes the fork: it closes on idle. - await release(key) - } finally { - await release(sourceKey) - } + const { fork: forked, key } = await forkInto(harness, { + threadId, + newThreadId: `thread-${crypto.randomUUID()}`, + at: 'before' in at ? { before: at.before } : at.through, + ...(user ? { user } : {}), + }) + // No handle takes the fork: it closes on idle. + await release(key) const mode = members ?? (access ? (access.rules.members?.fork ?? 'self') : undefined) diff --git a/packages/ai-harness/tests/seed.test.ts b/packages/ai-harness/tests/seed.test.ts index 15520172f9..a9a3936804 100644 --- a/packages/ai-harness/tests/seed.test.ts +++ b/packages/ai-harness/tests/seed.test.ts @@ -327,6 +327,24 @@ describe('session.seed', () => { await host.close() }) + it('forks before a tool result with an id inside a split UI message', async () => { + const { session, host, harness } = await open() + const messages = convertMessagesToModelMessages(CHAT).map((message) => + message.role === 'tool' ? { ...message, id: 'tool-1' } : message, + ) + await session.seed({ messages }) + + const entry = await host.sessions.fork({ + harness, + threadId: 't1', + at: { before: 'tool-1' }, + }) + const fork = await host.open(harness, { threadId: entry.threadId }) + + expect(idsOf(await fork.transcript())).toEqual(['u-1', 'a-1']) + await host.close() + }) + it('needs the send action on a host with access', async () => { interface User { id: string