Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
157c17f
fix(ai-claude-code,ai-codex,ai-grok-build): give the chat() request s…
AlemTuzlak Nov 2, 2026
603b2db
fix(ai-sandbox): kill the spawnNdjson process on early stop, forward …
AlemTuzlak Nov 2, 2026
8850d73
fix(ai-acp): cancel the question form when the operation ends
AlemTuzlak Nov 2, 2026
ffd0aa5
fix(ai-harness): give search commands the call signal, cancel harness…
AlemTuzlak Nov 2, 2026
996faee
fix(ai-mcp): handle tasks/cancel for task tools, stop a cancelled eli…
AlemTuzlak Nov 2, 2026
d889d91
fix(ai-memory): forward the call signal in the hindsight tools
AlemTuzlak Nov 2, 2026
d9b3c40
fix(ai-sandbox-daytona,ai-sandbox-vercel,ai-sandbox-upstash-box): cle…
AlemTuzlak Nov 2, 2026
9766372
fix(ai-code-mode): end the program and dispose the isolate when the r…
AlemTuzlak Nov 2, 2026
803cf31
fix(ai-computer-use): skip queued actions and end waits when the call…
AlemTuzlak Nov 2, 2026
1980aa9
docs: describe tasks/cancel, code mode abort, and harnessText early stop
AlemTuzlak Nov 2, 2026
ac4f193
revert(ai-memory): move the hindsight signal change to the feat PR
AlemTuzlak Nov 2, 2026
8d57271
revert(ai-sandbox-upstash-box): drop the session-based exec on abort
AlemTuzlak Nov 2, 2026
8486aed
fix(ai-code-mode, ai-computer-use, ai-harness, ai-sandbox-daytona, ai…
AlemTuzlak Nov 2, 2026
349955d
fix(ai-harness): start no search work for a call that aborted already
AlemTuzlak Nov 2, 2026
8a72cca
fix(ai-code-mode): make no isolate when the run aborts while the snip…
AlemTuzlak Nov 2, 2026
f65da49
fix(ai-computer-use): stop navigate and compound actions between step…
AlemTuzlak Nov 2, 2026
db2a554
docs(mcp): say that a task cancel aborts only the task in this process
AlemTuzlak Nov 2, 2026
a52b1a4
fix(ai-computer-use): reject a drag that aborts during its last move
AlemTuzlak Nov 2, 2026
f9e5f59
fix(ai-code-mode): load no snippets when the run aborts during transpile
AlemTuzlak Nov 2, 2026
d7b49de
fix(ai-harness): reject a search that the abort stops
AlemTuzlak Nov 2, 2026
905c1e5
fix(ai-computer-use): make no drag move after an abort during mouseDown
AlemTuzlak Nov 2, 2026
f241289
test(ai-computer-use): let abortingDuring take mouseDown
AlemTuzlak Nov 2, 2026
41df967
fix(ai-computer-use): finish a drag that started before the abort
AlemTuzlak Nov 3, 2026
ac64782
fix(ai-mcp): keep the result of a task that ends during tasks/cancel
AlemTuzlak Nov 3, 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
5 changes: 5 additions & 0 deletions .changeset/abort-acp-question-elicitation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-acp': patch
---

When a turn ends while the editor shows a question form (for example, another client answered it), the agent now sends `$/cancel_request` for that form. Before, the turn waited for the editor and did not report idle.
5 changes: 5 additions & 0 deletions .changeset/abort-cli-agent-tool-bridge-claude-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-claude-code': patch
---

The host tool bridge now gets the run signal from `chat()`. Before, it read only `abortController`, which `chat()` does not set, so a stopped run did not abort the bridged tools.
5 changes: 5 additions & 0 deletions .changeset/abort-cli-agent-tool-bridge-codex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-codex': patch
---

The host tool bridge now gets the run signal from `chat()`. Before, it read only `abortController`, which `chat()` does not set, so a stopped run did not abort the bridged tools.
5 changes: 5 additions & 0 deletions .changeset/abort-cli-agent-tool-bridge-grok-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-grok-build': patch
---

The host tool bridge of the `streaming-json` protocol now gets the run signal from `chat()`. Before, it read only `abortController`, which `chat()` does not set, so a stopped run did not abort the bridged tools.
5 changes: 5 additions & 0 deletions .changeset/abort-code-mode-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-code-mode': patch
---

An abort of the run now ends the `execute_typescript` program: a call that waits for an approval stops, and the isolate is disposed.
5 changes: 5 additions & 0 deletions .changeset/abort-computer-use-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-computer-use': patch
---

An aborted computer action no longer runs when it waits in the queue. A `wait`, `hold_key`, a repeated key press, a `navigate`, or the next action of a batch now stops when the call aborts. Held keys are still released. A drag does not start after an abort. A drag that started goes to its end point, because a release in the middle drops the item at the wrong point.
5 changes: 5 additions & 0 deletions .changeset/abort-harness-exec-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-harness': patch
---

The `list_files` and `grep` commands now get the abort signal of the tool call. `harnessText` now cancels the inner turn, or the remote turn, when the caller stops reading the stream early.
5 changes: 5 additions & 0 deletions .changeset/abort-mcp-task-cancel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-mcp': patch
---

`createMCPServer` now answers `tasks/cancel` for an `execution: 'task'` tool on spec 2025. The task status becomes `cancelled`, `ctx.abortSignal` of the tool aborts, and a late result does not replace the cancel. A spec 2025 `elicitation/create` that the server cancels now also stops the question in the harness.
5 changes: 5 additions & 0 deletions .changeset/abort-sandbox-daytona-late-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox-daytona': patch
---

`create` and `restoreSnapshot` now delete a sandbox that the SDK makes after the signal aborted. Before, that sandbox kept running with no owner.
5 changes: 5 additions & 0 deletions .changeset/abort-sandbox-spawn-ndjson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox': patch
---

`spawnNdjson` now kills the agent process when the consumer stops reading early. The `save_sandbox_snapshot` tool now gives the abort signal of the call to `snapshots.save`.
5 changes: 5 additions & 0 deletions .changeset/abort-sandbox-vercel-late-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox-vercel': patch
---

`create` now stops a sandbox that comes after the signal aborted. Before, that sandbox kept running with no owner.
2 changes: 1 addition & 1 deletion docs/code-mode/code-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Code Mode rejects tools that need approval. A tool marked `needsApproval: true`

### Stop tool calls when the run stops

When a user stops a chat run, a tool call that the sandbox started keeps going, unless the tool listens for the abort. Each `external_*` call gets the `abortSignal` of the chat run in its second argument. Pass it to the slow work:
When a user stops a chat run, Code Mode disposes the isolate. With the Node and E2B drivers the program stops at once. With other drivers, the running code can continue until it ends or until its timeout. A tool call that the program started keeps going, unless the tool listens for the abort. Each `external_*` call gets the `abortSignal` of the chat run in its second argument. Pass it to the slow work:

```typescript
import { toolDefinition } from "@tanstack/ai";
Expand Down
6 changes: 3 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@
"label": "MCP Server Tasks",
"to": "mcp/server-tasks",
"addedAt": "2026-09-22",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-02"
},
{
"label": "MCP Server Auth",
Expand Down Expand Up @@ -1007,7 +1007,7 @@
"label": "Code Mode",
"to": "code-mode/code-mode",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-29"
"updatedAt": "2026-11-02"
},
{
"label": "Showing Code Mode in the UI",
Expand Down Expand Up @@ -1092,7 +1092,7 @@
"label": "Connect clients",
"to": "harness/connect",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-02"
},
{
"label": "Write a client in any language",
Expand Down
2 changes: 1 addition & 1 deletion docs/harness/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ Each outer thread gets its own inner session, so the harness keeps its own histo
- Who runs the inner turn: when the outer `chat()` runs inside a harness turn, the inner session opens as the user of that turn. Else it opens as `user`, for example `harnessText(assistant, { host, user: { id: 'ada' } })`.
- Access: the inner thread is `<outer thread id>/inner/<harness name>`. When `host` has `access` and also runs the outer thread, the inner session is checked through the outer thread, so every user who can read a shared outer thread can use its inner session. From app code, `host.open`, `host.submit`, `host.fork`, and `host.transfer` refuse any thread id with `/inner/`.
- Usage: the outer run gets the token usage of every inner model call, so the usage totals of the outer session count them.
- Cancel: an abort of the outer call cancels the inner turn. For a remote harness, `harnessText({ url, token })` also tells the remote host to cancel the turn.
- Cancel: an abort of the outer call cancels the inner turn. A caller that stops reading the stream early cancels it too. For a remote harness, `harnessText({ url, token })` also tells the remote host to cancel the turn.

If a plugin picks the model of the harness, the harness has no adapter to read its input kinds from. Pass them with `harnessText(assistant, { host, inputModalities: ['text', 'image'] })`.

Expand Down
31 changes: 28 additions & 3 deletions docs/mcp/server-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The host calls `build_report` and gets a task handle at once. After 5 seconds, t
2. The handler returns a task handle before the work ends.
3. The tool function continues in this process. There is no queue.
4. When the tool function ends, the task store saves the value.
5. The host polls until the status is `completed` or `failed`.
5. The host polls until the status is `completed`, `failed`, or `cancelled`.

The default task store keeps each task in memory for this process. A TanStack AI host polls until the task ends. See [MCP Server Tools](../tools/mcp).

Expand All @@ -76,7 +76,31 @@ The task keeps running after the call returns the task handle. As a result, the

- `ctx.context.requestInput` throws an error. Ask for input in a tool without `execution: 'task'`.
- `ctx.context.sample` calls the `sample` option of `createMCPServer`. If you do not pass `sample`, it throws an error.
- `ctx.abortSignal` does not abort when the call request ends.
- `ctx.abortSignal` does not abort when the call request ends. It aborts when the host cancels the task. See [Stop a cancelled task](#stop-a-cancelled-task).

## Stop a cancelled task

A host sends `tasks/cancel` to stop a task. The task status becomes `cancelled`, and `ctx.abortSignal` of the tool aborts. Give that signal to the slow work, so the work stops too:

```ts
import { toolDefinition } from '@tanstack/ai'

const buildReport = toolDefinition({
name: 'build_report',
description: 'Build a long report',
execution: 'task',
}).server(async (_args, ctx) => {
const response = await fetch('https://reports.example.com/build', {
method: 'POST',
signal: ctx?.abortSignal,
})
return { text: await response.text() }
})
```

- A task that ended (`completed`, `failed`, or `cancelled`) cannot be cancelled. `tasks/cancel` then returns an error.
- A cancelled task keeps the status `cancelled`. A result that the tool returns after the cancel is not saved.
- Only the process that runs the task can abort its signal. With a shared store on more than one instance, a cancel on another instance saves `cancelled`, but the tool runs on. Its late result then replaces `cancelled`.

## Run tasks on a Worker

Expand Down Expand Up @@ -172,7 +196,7 @@ On spec 2025, the call response is the `CreateTaskResult` from the spec. The `ta
| Field | Value |
| --- | --- |
| `taskId` | The id from the call. |
| `status` | `working`, `completed`, or `failed`. |
| `status` | `working`, `completed`, `failed`, or `cancelled`. |
| `ttl` | `null`. |
| `createdAt` | The time of the first `set`. |
| `lastUpdatedAt` | The time of the last `set`. |
Expand All @@ -185,6 +209,7 @@ On spec 2025, the call response is the `CreateTaskResult` from the spec. The `ta
| `working` | The tool function is still at work. |
| `completed` | The store holds the tool result. |
| `failed` | The tool function threw an error. |
| `cancelled` | A host sent `tasks/cancel`. `ctx.abortSignal` of the tool aborts only in the process that runs the task. With a shared store, see [Stop a cancelled task](#stop-a-cancelled-task). |

If the tool function throws, the task keeps the text of that error in `statusMessage`. If that error has no text, the task keeps `The tool failed.`

Expand Down
23 changes: 16 additions & 7 deletions packages/ai-acp/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,12 +513,15 @@ export function createAcpAgent<U extends HarnessUser = HarnessUser>(
wait,
toolCallId,
url,
signal,
}: {
client: AgentContext
sessionId: string
wait: Wait
toolCallId?: string
url?: string
/** Sends `$/cancel_request` for the question when it aborts. */
signal?: AbortSignal
}): Promise<WaitAnswer> => {
if (!(await canAsk(client, wait, url))) return { status: 'cancelled' }
if (wait.kind === 'approval') {
Expand All @@ -539,12 +542,16 @@ export function createAcpAgent<U extends HarnessUser = HarnessUser>(
const scope = { sessionId, ...(toolCallId ? { toolCallId } : {}) }
if (wait.kind === 'question' || wait.kind === 'human') {
const form = formOf(wait)
const reply = await client.request('elicitation/create', {
...scope,
mode: 'form',
message: wait.message ?? 'The agent asks for input.',
requestedSchema: form.requestedSchema,
})
const reply = await client.request(
'elicitation/create',
{
...scope,
mode: 'form',
message: wait.message ?? 'The agent asks for input.',
requestedSchema: form.requestedSchema,
},
{ cancellationSignal: signal },
)
if (reply.action === 'cancel') return { status: 'stop' }
if (reply.action !== 'accept') return { status: 'cancelled' }
const content = 'content' in reply ? reply.content : undefined
Expand Down Expand Up @@ -655,7 +662,9 @@ export function createAcpAgent<U extends HarnessUser = HarnessUser>(
!turn && kind === 'question' && waitId === wait.waitId,
)
if (!isQuestion) return
const reply = await askEditor({ client, sessionId, wait })
// The operation can end while the editor shows the question (another
// client answered it). Then the editor gets a cancel for it.
const reply = await askEditor({ client, sessionId, wait, signal })
if (signal.aborted) return
if (reply.status !== 'resolved') {
await session.cancel({ operationId })
Expand Down
77 changes: 77 additions & 0 deletions packages/ai-acp/tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,83 @@ describe('ACP v2 agent', () => {
await host.close()
})

it('cancels the form elicitation of a question that another client answered', async () => {
const greet = durableTool(
toolDefinition({
name: 'greet',
description: 'Ask the user for a name',
inputSchema: { type: 'object', properties: {} },
}),
async (_args, { step }) => {
const answer = await step.ask({
name: 'name',
message: 'Name?',
schema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
},
})
return { greeted: answer }
},
)
const harness = defineHarness({
name: 'test/acp-answered-elsewhere',
adapter: scripted([toolTurn('greet', '{}'), textTurn('Greeted.')]),
tools: [greet],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const updates: Array<SessionUpdate> = []
let asked = false
// An editor that answers the question only when the agent cancels it.
const editor = client()
.onNotification('session/update', ({ params }) => {
updates.push(params.update)
})
.onRequest('elicitation/create', ({ signal }) => {
asked = true
return new Promise((resolve) => {
signal.addEventListener(
'abort',
() => resolve({ action: 'cancel' }),
{ once: true },
)
})
})

await editor.connectWith(createAcpAgent({ host, harness }), async (ctx) => {
await ctx.request('initialize', {
protocolVersion: 2,
info: { name: 'test-editor', version: '1.0.0' },
capabilities: { elicitation: { form: {} } },
})
const { sessionId } = await ctx.request('session/new', {
cwd: '/tmp',
mcpServers: [],
})
await ctx.request('session/prompt', {
sessionId,
prompt: [{ type: 'text', text: 'greet me' }],
})
await waitFor(() => expect(asked).toBe(true))
// Another client of the thread answers the question.
const other = await host.open(harness, { threadId: sessionId })
const question = other
.snapshot()
.pendingWaits.find((wait) => wait.kind === 'question')
if (!question) throw new Error('No question waits')
await other.answer({
questionId: question.waitId,
value: { name: 'Ada' },
})
await waitFor(() => expect(isIdle(updates)).toBe(true))
await other.close()
})

expect(agentText(updates)).toContain('Greeted.')
await host.close()
})

it('refuses a second connection to one app, and keeps the first', async () => {
const greet = durableTool(
toolDefinition({
Expand Down
4 changes: 3 additions & 1 deletion packages/ai-claude-code/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,9 @@ export class ClaudeCodeTextAdapter<
...(permission !== undefined ? { permission } : {}),
...(options.abortController?.signal
? { signal: options.abortController.signal }
: {}),
: options.request?.signal
? { signal: options.request.signal }
: {}),
})
}

Expand Down
39 changes: 39 additions & 0 deletions packages/ai-claude-code/tests/tool-choice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,42 @@ describe('claude-code toolChoice', () => {
expect(warn).not.toHaveBeenCalled()
})
})

describe('tool bridge signal', () => {
// chat() passes the run signal as `request.signal` and sets no
// `abortController`, so the bridge must take `request.signal`.
it('gives the chat() request signal to the tool bridge', async () => {
const sbx = await provider.create({})
try {
const capabilities = {
capabilities: { markProvided: () => {}, has: () => true },
} as unknown as CapabilityContext
provideSandbox(capabilities, sbx)
let bridgeSignal: AbortSignal | undefined
provideToolBridgeProvisioner(capabilities, {
provision: (_tools, options) => {
bridgeSignal = options.signal
return Promise.reject(new Error('stop after the bridge'))
},
})
const adapter = claudeCodeText('haiku', {
claudeExecutable: 'node fake-claude.mjs',
})
const signal = new AbortController().signal
await collect(
adapter.chatStream({
threadId: 'thread-1',
model: 'haiku',
messages: [{ role: 'user', content: 'hi' }],
logger: loggerWith(() => {}),
capabilities,
tools,
request: { signal },
}),
).catch(() => undefined)
expect(bridgeSignal).toBe(signal)
} finally {
await sbx.destroy()
}
})
})
Loading
Loading