Skip to content

background-task-started lifecycle chunk is dropped before reaching the UI stream in handleChatStream #18464

Description

@panliji

background-task-started lifecycle chunk is dropped before reaching the UI stream in handleChatStream

Summary

First off — thank you for the background tasks feature, it's a genuinely great
fit for "fire and keep chatting" UX. We're running into one issue that currently
makes it hard to consume the task lifecycle from a web-chat UI.

When an agent dispatches a tool as a background task, Mastra emits a structured
background-task-started chunk on the agent stream carrying the real taskId
({ taskId, toolName, toolCallId }). In a standard web-chat setup that routes
through @mastra/ai-sdk's handleChatStream()agent.stream()
toAISdkStream(), this chunk appears to be dropped on two independent paths
and never reaches the UI. As a result consumers cannot obtain the taskId from
the documented lifecycle event, and are forced to regex-parse it out of the
tool-result string instead.

This is meant as a constructive report — happy to refine it or provide more
repro detail if that helps.

Why we need taskId (real-world impact)

Background tasks are explicitly designed for the "fire and keep chatting" UX:
the agent dispatches a long-running tool, returns an immediate acknowledgement,
and the user keeps talking in the same thread while the task runs. To make that
UX actually work, the frontend needs the taskId at dispatch time for several
concrete things — all of which are currently blocked without the chunk:

  1. Render a live progress card at dispatch time. We show a "task running"
    card the moment the task is dispatched so the user sees something is
    happening and can keep chatting. Without taskId, the card cannot be created
    until the task finishes — which for a multi-minute research task defeats the
    entire point of running it in the background.

  2. Subscribe to the task's lifecycle out-of-band. Because the agent's stream
    returns immediately after dispatch (it does not block on the task), the only
    way for the UI to observe running → output → completed/failed is to open a
    separate subscription scoped to the task — e.g. backgroundTaskManager.stream({ taskId })
    / getTask(taskId) over a dedicated SSE endpoint. That subscription is keyed
    on taskId; no taskId, no way to follow the task.

  3. Show streamed output / the final report. Our deep-research task streams
    its output incrementally and writes a report on completion. Both arrive via
    the task-scoped manager stream as background-task-output /
    background-task-completed, which the UI can only receive once it has the
    taskId.

  4. Render a terminal state instead of an infinite spinner. When a background
    task fails, the failure is reported as background-task-failed on the
    manager stream. Without a taskId-driven subscription, the UI never learns
    the task died and the card spins forever.

In short: taskId is the handle that makes a background task observable from
the UI at all.
The whole value proposition of background tasks (non-blocking,
keep-chatting, live progress, eventual result) depends on the frontend getting
that handle at dispatch time — which is exactly what background-task-started
was meant to provide.

Versions

  • @mastra/core: 1.42.0
  • @mastra/ai-sdk: 1.4.5
  • ai: 6.0.188

Evidence

1. Mastra does emit the chunk

In the agent's tool-execution loop, when a background task is dispatched
(fallbackToSync === false), the background-task-started chunk is enqueued
onto the stream controller with a structured payload, and the tool returns the
human-readable acknowledgement as its result:

const { task, fallbackToSync } = await bgTask.dispatch();
if (!fallbackToSync) {
  safeEnqueue(controller, {
    type: "background-task-started",
    runId,
    from: "AGENT",
    payload: {
      taskId: task.id,
      toolName: inputData.toolName,
      toolCallId: inputData.toolCallId,
    },
  });
  return {
    result: `Background task started. Task ID: ${task.id}. The tool "${inputData.toolName}" is running in the background. You will be notified when it completes.`,
    ...inputData,
  };
}

So the chunk exists, carries exactly the structured { taskId, toolName, toolCallId }
a UI needs, and is placed onto the agent stream controller.

2. Path A — onChunk is not invoked for it

In the agent loop, the onChunk option is only invoked for a fixed allowlist of
chunk types:

default:
  safeEnqueue(controller, chunk);   // ← chunk IS enqueued to the stream...
}
if ([
  "text-delta",
  "reasoning-delta",
  "source",
  "tool-call",
  "tool-call-input-streaming-start",
  "tool-call-delta",
  "tool-call-input-streaming-end",
  "raw",
].includes(chunk.type)) {
  if (chunk.type === "raw" && !includeRawChunks) {
    continue;
  }
  await options?.onChunk?.(chunk);   // ← ...but onChunk is NOT called for background-task-started
}

So a consumer passing onChunk to handleChatStream / agent.stream()
expecting to observe background-task-started never receives it.

3. Path B — toAISdkStream transformer discards it

@mastra/ai-sdk's convertFullStreamChunkToUIMessageStream returns null for
any chunk type it doesn't explicitly handle and that isn't data- prefixed:

default: {
  if (isDataChunkType(payload)) {
    // ... pass through data-* chunks
  }
  return null;   // ← background-task-started falls through here and is dropped
}

There's no handling of any background-task-* chunk type anywhere in
@mastra/ai-sdk (a grep for background-task returns no matches in the
package), so the chunk — even though present on the agent fullStream — is
filtered out before reaching the AI SDK UI message stream.

Current (fragile) workaround

Because the chunk is unreachable, we're currently extracting the taskId by
regex-matching the tool-result string:

// tool-result is a plain string, set by Mastra at dispatch time:
// "Background task started. Task ID: <id>. The tool \"...\" is running in the background."
const match = output.match(/Background task started\.\s+Task ID:\s+([^\s.]+)/i);
const taskId = match?.[1]?.trim();

This works today but is brittle — it depends on a natural-language string and
its exact wording, spacing, and punctuation, and it throws away the structured
payload the chunk already carries. It would be great to not have to rely on it.

Expected behavior

One (or both) of these would resolve it cleanly:

  1. @mastra/core: include background-task-started (and ideally the other
    agent-stream background-task chunks) in the onChunk allowlist so the
    callback fires for them.
  2. @mastra/ai-sdk: have toAISdkStream / convertFullStreamChunkToUIMessageStream
    pass background-task-* chunks through (e.g. as data- data parts) so they
    survive into the UI message stream the AI SDK UI consumes.

Either change would let a web-chat consumer obtain the structured
{ taskId, toolName, toolCallId } from the lifecycle event directly, instead of
regex-parsing the tool-result string.

Reproduction

import { handleChatStream } from "@mastra/ai-sdk";

// 1. Configure an agent with backgroundTasks.tools enabled for some tool,
//    and backgroundTasks.enabled on the Mastra instance.
// 2. Route a web chat through handleChatStream (version: "v6").
// 3. Trigger a prompt that makes the model call the background-eligible tool.

const stream = await handleChatStream({
  mastra,
  agentId,
  version: "v6",
  params: { messages, memory: { thread: tId, resource: rId } },
  defaultOptions: {
    onChunk: (chunk) => {
      console.log("chunk:", chunk.type);
      // You'll see tool-call, tool-result, text-delta, reasoning-delta...
      // ...but never "background-task-started", even though the task was
      // dispatched and is visible via backgroundTaskManager.getTask(taskId).
    },
  },
});

Querying mastra_background_tasks (or backgroundTaskManager.getTask) confirms
the task was created and started, so dispatch definitely happened — the chunk
simply never propagates to the UI layer.

Additional context

  • The manager API backgroundTaskManager.stream() does emit background-task-running
    / -completed / etc., which we already use for the post-dispatch lifecycle.
    But those arrive on a long-lived stream that stays open until the caller's
    AbortSignal fires, and running fires when the task starts executing (not
    when it's enqueued). Wiring that into a request-scoped web-chat stream
    introduces timing/ordering complexity that background-task-started on the
    agent stream was presumably meant to avoid.
  • The embedded background-tasks doc (docs-agents-background-tasks.md) lists
    background-task-started as an agent-stream chunk type with payload
    { taskId, toolName, toolCallId }, which reads as though it's meant to be
    consumable from the agent's own stream.
  • Related (different root cause, same area): Tool-level background config is silently ignored — createTool constructor never reads opts.background #18451 (tool-level background
    config silently ignored) and Background-dispatched sub-agent delegations send null tool-message content (provider 500) #17798 (background sub-agent delegations send
    null tool-message content).

Thanks again for the great work on Mastra, and please let me know if there's
anything I can clarify or test on our side. 🙏

Metadata

Metadata

Assignees

No one assigned

    Labels

    AgentsIssues regarding Mastra's Agent primitiveToolsIssues with user made tools for Agent tool callingbugSomething isn't workingeffort:mediumimpact:hightrio-tb

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions