Skip to content
5 changes: 5 additions & 0 deletions .changeset/harness-session-seed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 38 additions & 4 deletions docs/harness/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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<Array<UIMessage>>

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
Expand Down Expand Up @@ -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.
87 changes: 27 additions & 60 deletions packages/ai-harness/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
createInputId,
createMessageId,
offeredInputRecord,
settledMessages,
storedUser,
} from './session/helpers'
import { observedSession } from './session/observed-session'
Expand Down Expand Up @@ -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<ModelMessage>) {
const open = new Set<string>()
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
Expand Down Expand Up @@ -2779,7 +2762,15 @@ export function createHarnessHost<U extends HarnessUser = HarnessUser>(
*/
const forkInto = async <THarness extends AnyHarness>(
harness: THarness,
{ threadId, newThreadId, at, user }: ForkSessionOptions<U>,
{
threadId,
newThreadId,
at,
user,
}: Omit<ForkSessionOptions<U>, '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.')
Expand All @@ -2797,15 +2788,21 @@ export function createHarnessHost<U extends HarnessUser = HarnessUser>(
// 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
: transcript.findIndex((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.
Expand Down Expand Up @@ -3521,44 +3518,14 @@ export function createHarnessHost<U extends HarnessUser = HarnessUser>(
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)
Expand Down
5 changes: 3 additions & 2 deletions packages/ai-harness/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelMessage>,
Expand All @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions packages/ai-harness/src/session/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,8 @@ export function sessionHandle<THarness extends AnyHarness>({
revert: (options: Parameters<Session<THarness>['revert']>[0]) =>
acting('send', (target) => target.revert(as(options))),
unrevert: () => acting('send', (target) => target.unrevert()),
seed: (options: Parameters<Session<THarness>['seed']>[0]) =>
acting('send', (target) => target.seed(options)),
moveToolCallToBackground: (
options?: Parameters<Session<THarness>['moveToolCallToBackground']>[0],
) =>
Expand Down
1 change: 1 addition & 0 deletions packages/ai-harness/src/session/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type PublicSessionMember =
| 'runCommand'
| 'runTool'
| 'schedule'
| 'seed'
| 'send'
| 'setPluginSetting'
| 'settled'
Expand Down
18 changes: 18 additions & 0 deletions packages/ai-harness/src/session/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,3 +1194,21 @@ type LinkedRun = PromiseLike<unknown> & {
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<ModelMessage>) {
const open = new Set<string>()
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)
}
Loading