Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e3de43e
feat(ai): check tool input at the last moment, after approval edits a…
AlemTuzlak Oct 13, 2026
3ff07fd
test(ai): fix the boundary client tool tests
AlemTuzlak Oct 13, 2026
76a687d
feat(ai): validate JSON Schema tool input with @cfworker/json-schema
AlemTuzlak Oct 13, 2026
fff8daf
chore: keep one @google/genai copy for ai-vertex and ai-gemini
AlemTuzlak Oct 13, 2026
e065ccb
feat(ai): repair common model mistakes in tool input before the final…
AlemTuzlak Oct 13, 2026
3dcb866
fix(ai): type the repair order list
AlemTuzlak Oct 13, 2026
68de395
feat(ai, adapters): keep the raw tool argument string for replay
AlemTuzlak Oct 13, 2026
a281b65
Merge remote-tracking branch 'origin/main' into feat/tool-input-final…
AlemTuzlak Oct 13, 2026
7ac7c1b
test(ai): the subagent tool input is checked after middleware
AlemTuzlak Oct 13, 2026
e4457c3
Merge branch 'feat/tool-input-final-check' into feat/json-schema-tool…
AlemTuzlak Oct 13, 2026
a3bece7
Merge branch 'feat/json-schema-tool-input' into feat/tool-input-coercion
AlemTuzlak Oct 13, 2026
746fbd7
Merge branch 'feat/tool-input-coercion' into feat/raw-tool-arguments
AlemTuzlak Oct 13, 2026
9f37341
test(ai): subagent schema errors also show the received arguments
AlemTuzlak Oct 13, 2026
5eae0ac
Merge branch 'feat/tool-input-final-check' into feat/json-schema-tool…
AlemTuzlak Oct 13, 2026
e147eb0
test(ai): the JSON Schema of a subagent tool checks background
AlemTuzlak Oct 13, 2026
755c79f
Merge branch 'feat/json-schema-tool-input' into feat/tool-input-coercion
AlemTuzlak Oct 13, 2026
eb449a0
test(ai): use an input the repair cannot fix in the subagent schema test
AlemTuzlak Oct 13, 2026
b202b0e
Merge branch 'feat/tool-input-coercion' into feat/raw-tool-arguments
AlemTuzlak Oct 13, 2026
163dfdb
Merge remote-tracking branch 'origin/feat/raw-tool-arguments' into fe…
AlemTuzlak Oct 13, 2026
1ac34ad
feat(ai, ai-persistence): keep approved edits raw until the final che…
AlemTuzlak Oct 13, 2026
3294952
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
8a658c9
test(ai-harness): adapt the replay parity test to the tool-input stack
AlemTuzlak Oct 13, 2026
6a3881a
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 14, 2026
6a8ed65
test(ai-harness): expect media in turn results after #1779
AlemTuzlak Oct 14, 2026
29ef318
Merge feat/harness-core (with main) into feat/harness-interrupt-resume
AlemTuzlak Oct 16, 2026
1d0a6e9
test(ai-harness): use session.send() after the core send() change
AlemTuzlak Oct 16, 2026
94d411e
fix(ai): keep an approved edit only when its call passed the check
AlemTuzlak Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/approval-raw-edits-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai': minor
'@tanstack/ai-persistence': patch
---

Approved edits to tool arguments stay raw until the final input check.

- `@tanstack/ai`: a resume no longer rejects approved `editedArgs` that do not fit the tool's `inputSchema`. The edit goes through `onBeforeToolCall` and the final input check, like the model's arguments. If it still does not fit, the approval is used, the tool does not run, and the model gets the input error as the tool result. The transcript keeps the approved edit as it was sent, but only when the call passed the check.
- `@tanstack/ai-persistence`: `withPersistence` binds the client-tool phase of an approved tool call to its approval. After a restart, the client result resumes with the approval that came before it, and a stale or missing approval fails the resume.
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@
"label": "Tool Approval",
"to": "interrupts/tool-approval",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
"updatedAt": "2026-10-13"
},
{
"label": "Multiple Interrupts",
Expand Down
14 changes: 12 additions & 2 deletions docs/interrupts/tool-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Now the decision carries a payload, and approval can also replace the arguments:
interrupt.resolveInterrupt(true, { payload: { note: 'Reviewed' } })

// Approve, but replace the arguments first. editedArgs is a full replacement,
// not a merge, and is validated against the tool's inputSchema.
// not a merge. The tool's inputSchema checks it just before the tool runs.
interrupt.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Capped to policy' },
Expand All @@ -206,7 +206,17 @@ interrupt.resolveInterrupt(false, { payload: { reason: 'Too large' } })

Only approval accepts `editedArgs`. Without an `approvalSchema` the boolean
shorthand `resolveInterrupt(true)` / `resolveInterrupt(false)` is all you need.
The server re-validates the whole decision before it runs the tool.
The server checks the whole decision again when it resumes.

The tool's `inputSchema` checks `editedArgs` after middleware, just before the
tool runs, the same as the model's arguments. On the server, edited arguments
that do not fit do not fail the resume. The approval is used, the tool does not
run, and the model gets the input error as the result of that call. See
[Server Tools](../tools/server-tools).

The browser client also checks `editedArgs` against the tool's `inputSchema`
when it has the schema, and it refuses an edit that does not fit before it
sends the decision.

## Consume the decision on the server

Expand Down
202 changes: 202 additions & 0 deletions packages/ai-harness/tests/interrupt-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,122 @@ describe.each([false, true])(
},
)

it('consumes an approval after a final input error without executing its tool', async () => {
const stores = memoryPersistence().stores
const persistence = durable
? {
stores: {
log: memoryLogStore(),
runs: stores.runs,
metadata: stores.metadata,
interrupts: stores.interrupts,
},
}
: { stores }
const remove = removeTool()
const before = vi.fn()
const main = mockAdapter([
() => toolCall('remove', { path: 'a.txt' }, 'invalid-final-call'),
() => text('Input rejected.'),
])
const config = defineHarness({
name: 'invalid-final-approval',
adapter: main.adapter,
tools: [remove.tool],
middleware: [{ name: 'observe', onBeforeToolCall: before }],
})
const host = createHarnessHost({ persistence })
const session = await host.open(config, { threadId: THREAD })
const initial = await session.send('remove')
const approval = initial.interrupts?.[0]
if (!approval) throw new Error('The turn did not stop for approval.')
const receipt = await session.resolve([
{
interruptId: approval.id,
status: 'resolved',
payload: { approved: true, editedArgs: { path: {} } },
},
])
await expect(
session.operation(receipt.operationId ?? ''),
).resolves.toEqual({ text: 'Input rejected.', media: [] })
expect(before).toHaveBeenCalledTimes(1)
expect(remove.execute).not.toHaveBeenCalled()
expect(session.snapshot().pendingInterrupts).toEqual([])
expect((await stores.interrupts.get(approval.id))?.status).toBe(
'resolved',
)
await host.close()
})

it('keeps approval after schema drift and retries with the original schema', async () => {
const stores = memoryPersistence().stores
const persistence = durable
? {
stores: {
log: memoryLogStore(),
runs: stores.runs,
metadata: stores.metadata,
interrupts: stores.interrupts,
},
}
: { stores }
const original = removeTool()
const main = mockAdapter([
() => toolCall('remove', { path: 'a.txt' }, 'drift-call'),
() => text('Done.'),
])
const config = defineHarness({
name: 'schema-drift',
adapter: main.adapter,
tools: [original.tool],
})
const first = createHarnessHost({ persistence })
const session = await first.open(config, { threadId: THREAD })
const initial = await session.send('remove')
const approval = initial.interrupts?.[0]
if (!approval) throw new Error('The turn did not stop for approval.')
await first.close()
const changedExecute = vi.fn(async () => 'must not run')
const changedTool = toolDefinition({
name: 'remove',
description: 'Remove',
needsApproval: true,
inputSchema: z.object({ path: z.number() }),
}).server(changedExecute)
const changed = createHarnessHost({ persistence })
const changedSession = await changed.open(
defineHarness({
name: 'schema-drift',
adapter: main.adapter,
tools: [changedTool],
}),
{ threadId: THREAD },
)
const rejected = await changedSession.resolve([
{ interruptId: approval.id, status: 'resolved', payload: true },
])
await expect(
changedSession.operation(rejected.operationId ?? ''),
).rejects.toThrow()
expect(changedExecute).not.toHaveBeenCalled()
expect(original.execute).not.toHaveBeenCalled()
expect(
changedSession.snapshot().pendingInterrupts.map((item) => item.id),
).toEqual([approval.id])
await changed.close()
const next = createHarnessHost({ persistence })
const retrySession = await next.open(config, { threadId: THREAD })
const retry = await retrySession.resolve([
{ interruptId: approval.id, status: 'resolved', payload: true },
])
await expect(
retrySession.operation(retry.operationId ?? ''),
).resolves.toEqual({ text: 'Done.', media: [] })
expect(original.execute).toHaveBeenCalledTimes(1)
await next.close()
})

it.each(['cancel', 'close'])(
'keeps approval when %s stops an admitted resolve before execution',
async (kind) => {
Expand Down Expand Up @@ -521,6 +637,92 @@ describe.each([false, true])(
},
)

it('recovers the new client phase after metadata replacement fails', async () => {
const stores = memoryPersistence().stores
const persistence = durable
? {
stores: {
log: memoryLogStore(),
runs: stores.runs,
metadata: stores.metadata,
interrupts: stores.interrupts,
},
}
: { stores }
const tool = toolDefinition({
name: 'check',
description: 'Check',
needsApproval: true,
inputSchema: z.object({ count: z.number() }),
outputSchema: z.object({ ok: z.boolean() }),
}).client()
const main = mockAdapter([
() => toolCall('check', { count: 1 }, 'phase-call'),
() => text('Checked.'),
])
const config = defineHarness({
name: 'phase-recovery',
adapter: main.adapter,
tools: [tool],
})
const first = createHarnessHost({ persistence })
const session = await first.open(config, { threadId: THREAD })
const initial = await session.send('check')
const approval = initial.interrupts?.[0]
if (!approval) throw new Error('The turn did not stop for approval.')
const metadata = vi
.spyOn(stores.metadata, 'set')
.mockRejectedValueOnce(new Error('metadata replacement failed'))
const accepted = await session.resolve([
{
interruptId: approval.id,
status: 'resolved',
payload: { approved: true, editedArgs: { count: '2' } },
},
])
await session.operation(accepted.operationId ?? '')
metadata.mockRestore()
const pending = session.snapshot().pendingInterrupts
expect(pending).toHaveLength(1)
const client = pending[0]
if (!client) throw new Error('The client phase is missing.')
expect(client.id).not.toBe(approval.id)
expect(client.metadata).toMatchObject({
kind: 'client_tool',
input: { count: 2 },
})
expect((await stores.interrupts.get(approval.id))?.status).toBe(
'resolved',
)
await first.close()
const next = createHarnessHost({ persistence })
const reopened = await next.open(config, { threadId: THREAD })
expect(
reopened.snapshot().pendingInterrupts.map((item) => item.id),
).toEqual([client.id])
const wrong = await reopened.resolve([
{
interruptId: client.id,
status: 'resolved',
payload: { ok: 'wrong' },
},
])
await expect(
reopened.operation(wrong.operationId ?? ''),
).rejects.toThrow()
expect(
reopened.snapshot().pendingInterrupts.map((item) => item.id),
).toEqual([client.id])
const valid = await reopened.resolve([
{ interruptId: client.id, status: 'resolved', payload: { ok: true } },
])
await expect(
reopened.operation(valid.operationId ?? ''),
).resolves.toEqual({ text: 'Checked.', media: [] })
expect(reopened.snapshot().pendingInterrupts).toEqual([])
await next.close()
})

it('keeps foreign pending descriptors outside the harness batch', async () => {
const stores = memoryPersistence().stores
const persistence = durable
Expand Down
Loading
Loading