Skip to content
Harness

Undo a turn

The agent changed five files, and the result is wrong. You want the files back as they were before that turn, and you do not want a reset in your own git repository. snapshots() saves the files of the workspace at each model step, in a git repository of its own. /undo puts the files back and removes the turn from the conversation.

1. Add the plugin

ts
import { homedir } from 'node:os'
import { join } from 'node:path'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'

const root = process.cwd()
const dataDir = join(homedir(), '.acme-coder', 'snapshots')
const history = snapshots({ root, dataDir })

const coder = defineHarness({
  name: 'acme/coder',
  adapter: openaiText('gpt-6.1-sol'),
  plugins: () => [permissions({ root }), workspaceTools({ root }), history],
})

const persistence = memoryPersistence()
const { log } = persistence.stores
const host = createHarnessHost({ persistence })
const session = await host.open(coder, { threadId: 'fix-login' })
  • root is the workspace folder, an absolute path.
  • dataDir is a folder for the snapshot repositories, an absolute path. Put it outside root. Each thread of each tenant gets its own repository in it.
  • The plugin needs the git command. /undo and /redo need git 2.26 or later.
  • Your project does not need to be a git repository, and the plugin never uses the .git folder of your project.
  • With workspaceTools({ workspace: { bind } }) before it, each thread keeps its snapshots in its own workspace. Leave out root.

2. Undo and redo a turn

  1. Send a prompt that changes files: await session.send('Fix the login form.').
  2. Run await session.runCommand({ name: 'undo' }). It returns the number of files that it put back, for example Undid the last turn. Files restored: 3.
  3. Look at the files that the turn changed: they are as they were before the turn. The turn is gone from the conversation too.
  4. To bring back the files and the turn, run await session.runCommand({ name: 'redo' }). It returns Redid the last turn.

In a terminal app, the user types /undo and /redo. See Build a coding agent.

  • /undo puts back each file that the turn changed in root. This includes the edits of the file tools and the changes of bash commands.
  • /undo and /redo do not touch other files. This is also true for a file that you add or edit after the turn.
  • The snapshots skip the files that .gitignore names, like node_modules, and .env files. /undo does not touch them.
  • A new turn clears /redo.

When someone else edits the same files:

  • If you edit a file that the turn changed, /undo and /redo still write over it, and your edit is lost.
  • A step keeps only the files that its own run changed. So when another agent or session edits a file during the turn, /undo keeps that edit.
  • A bash command does not tell which files it changed. So its step keeps every file that changed while the command ran.

In your own UI, add expose: { commands: ['undo', 'redo'] } to defineHarness. A client can run only the commands that the harness exposes. Then call client.runCommand({ name: 'undo' }). The result text arrives as a harness.command.result event.

See what a step changed

Each model step that changed files leaves a step: { from, to, files, toolCallIds }. from and to are the ids of two snapshots. files lists the changed paths, and toolCallIds lists the tool calls of the step. history.diff(from, to) gives the changed files and a unified diff.

The steps are tanstack/snapshots:step records in the session log:

ts
import { logIdOf } from '@tanstack/ai-harness'

await session.send('Fix the login form.')

for (const { record } of await log.read({ logId: logIdOf({ logId: 'fix-login' }) })) {
  if (record.type !== 'tanstack/snapshots:step') continue
  if (typeof record.from !== 'string' || typeof record.to !== 'string') continue
  const { files, patch } = await history.diff(record.from, record.to)
  console.log(files.join(', '))
  console.log(patch)
}
  • Without to, diff compares with the files now. history.diff(record.to) shows what changed after that step.
  • diff finds a snapshot only in the repository of a session that is open.
  • A step that changed no file is not saved.

The file tools send a FileChanged event for each file that they change, with its diff. A bash command sends none. So at the end of each model step, snapshots() sends FileChanged for each file of the step that no tool reported. Listen for all of them in a view:

ts
import { FileChanged } from '@tanstack/ai-harness/plugins/coding'
import { createSessionView } from '@tanstack/ai-harness/view'

const view = createSessionView(session)
view.on(FileChanged, (file) => console.log(`${file.change} ${file.path}`))

When the model runs pnpm format with bash, the handler prints one line for each file that the command changed, for example updated /work/repo/src/app.ts. A tool like bash does not report the files that it changes. If the step has one call of such a tool, each event has the toolCallId of that call. See also the known limits of these events.

/undo, /redo, a revert, and unrevert() also send FileChanged for each file that they change, with no toolCallId. So a diff pane, or a copy of the files that uses these events, stays correct.

Go back to an earlier message

/undo goes back one turn. To go back more turns, revert the session to a message of the transcript:

ts
const messages = await session.transcript()
const firstAnswer = messages.find((message) => message.role === 'assistant')
if (firstAnswer?.id) await session.revert({ messageId: firstAnswer.id })

// Bring back the files and the messages.
await session.unrevert()
  • A revert uses the last 500 steps of the thread. The log snapshot keeps them, so a revert also works with keepLogRecords: 'since-snapshot'.
  • A revert touches only the files that the tool calls after that message changed. Each file goes back to its state before the first of these calls. A file that did not exist then is removed.
  • Other files stay as they are. Your own edits between the hidden turns stay too.
  • unrevert() touches only the same files. It puts them back as they were when the revert started, with your edits.
  • A prompt that you send during revert() or unrevert() waits until it ends. Then the turn runs.
  • transcript() hides the messages after that message until unrevert() or the next prompt.
  • The next prompt drops the hidden messages for good. Then unrevert() does not change the files.
  • The revert is saved, so unrevert() still works after a restart.

In your own UI, call client.revert({ messageId }) and client.unrevert(). The client needs 'undo' in expose.commands. More about reverts: Go back to an earlier message.

In a sandbox

When the coding tools run in a sandbox, give snapshots() the same backend:

ts
import { snapshots } from '@tanstack/ai-harness/plugins/coding'
import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'

const sandbox = await dockerSandbox({ image: 'node:22' }).create({})

export const sandboxHistory = snapshots({
  root: '/workspace',
  dataDir: '/tmp/snapshots',
  backend: sandboxWorkspaceBackend(sandbox),
})

Git then runs in the sandbox, and dataDir is a path in the sandbox. The sandbox image needs git.

On more than one server

The undo point and the steps are in the session log. So when another server takes the thread, /undo, /redo, and a revert work there too, if that server reaches the same snapshot repository:

  • In a sandbox, the repository is in the sandbox. A server that uses the same sandbox reaches it. See A sandbox.
  • On the host, give every server the same dataDir on a shared disk. Or pin each thread to the server that has its folder with ownership: { hostId }. See A folder on one server.

Remove the snapshots of a thread

host.sessions.delete removes the snapshot repository of the thread for you. This needs two things:

  • The thread has an index entry.
  • This host knows the harness: it opened a session of it before, or it has the harness in its harnesses option.

In other cases, call purgeSnapshots with the options of the plugin and the thread:

ts
import { purgeSnapshots } from '@tanstack/ai-harness/plugins/coding'

await purgeSnapshots({ root, dataDir }, { tenantId: 'acme', threadId: 'fix-login' })
  • Call it after the sessions of the thread close. See Lifecycle and retention.
  • Leave out tenantId for a thread in the default tenant.
  • It waits for the git commands of that repository in this process.
  • It resolves also when there is no repository.

Known limits

  • One level of undo. A second /undo answers Nothing to undo. After a /redo, /undo works again.
  • /undo and /redo work only when the session is idle. During a turn, they answer Wait until the turn ends, then try again.
  • While a revert stands, /undo and /redo answer A revert stands. Run unrevert, or send a prompt, first.
  • If you sent a message while the turn ran, /undo removes the conversation from that message on. The files still go back to the start of the turn.
  • Without git, the plugin sends a tanstack/snapshots:failed event (SnapshotFailed) with snapshots: git was not found. Snapshots are off., and adds no commands. diff() throws. The turns run as usual. A snapshot that fails for another reason sends the same event, and the turn goes on.

For the FileChanged events of the plugin:

  • At most 100 events for each model step, and for each /undo, /redo, revert, or unrevert(). When more files changed, the plugin sends the first 100.
  • No 'moved' event. A file that bash moves gives a 'deleted' event for the old path and a 'created' event for the new path.
  • The plugin does not check the permission rules. So a file that read_file asks about or denies can get a patch.
  • No event for .env files and for the files that .gitignore names. The snapshots skip these files.

What you have now

  • /undo and /redo for the file changes of the last turn, also the changes of shell commands.
  • A revert to any earlier message, with its files, and unrevert() to bring them back.
  • A list of changed files and a unified diff for each model step.
  • A live FileChanged diff for each file that a bash command, /undo, /redo, or a revert changes.
  • Snapshots that never touch the git repository of your project.