diff --git a/.changeset/harness-session-seed.md b/.changeset/harness-session-seed.md new file mode 100644 index 0000000000..5b70631e62 --- /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`), 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/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..bb27df8700 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 @@ -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. @@ -149,12 +149,46 @@ 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. - `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`. 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. +- 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 ```ts group=harness-sessions @@ -327,7 +361,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. diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 2da4f02fb5..0f06eb13dd 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 @@ -2802,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/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/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..cec0ee26f6 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -110,6 +110,62 @@ class AnswerRefused extends Error { } } +/** + * 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 +} + +/** + * 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 + * 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() + 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 ?? []) { + if (open.has(call.id)) return 'open_tool_call' + 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. @@ -122,6 +178,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() /** @@ -878,24 +936,28 @@ 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 - * 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 + * 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 * handle. * * @example @@ -918,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. @@ -952,6 +1014,79 @@ 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. 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 + * 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 + * 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 + }) + // 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 + // 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 (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] }) + 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..15520172f9 --- /dev/null +++ b/packages/ai-harness/tests/seed.test.ts @@ -0,0 +1,362 @@ +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.' }, +] + +/** + * 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) + +/** 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 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('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 = [ + { 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.')]) + 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 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('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('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 + 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() + }) +})