Skip to content

Client sees "Task <id> failed" instead of the real error/result on failed tasks #1922

@jirispilka

Description

@jirispilka

I initially started with a PR (the fix itself is small), but the returned shape changes for existing consumers, so wanted to raise it first. I would be happy to work on this PR.

For reference, we experienced this issue while adding support for long-running async tasks for Actors in Apify MCP server - see failed task do not return results and apify/apify-mcp-server#685 (comment)"

Describe the bug

From the spec:

For tasks in a terminal status, receivers MUST return from tasks/result exactly what the underlying request would have returned, whether that is a successful result or a JSON-RPC error.

So when a task fails, we're blind to the real error - the client just gets Task <id> failed.

When a task-augmented request reaches failed status, TaskManager.requestStream doesn't call tasks/result it just yields a hardcoded ProtocolError(InternalError, "Task <id> failed"). So whatever the handler stored (e.g. { isError: true, content: [...] }) is unreachable.

// packages/core/src/shared/taskManager.ts:303
if (isTerminal(task.status)) {
    switch (task.status) {
        case 'completed': {
            const result = await this.getTaskResult({ taskId }, resultSchema, options);
            yield { type: 'result', result };
            break;
        }
        case 'failed': {
            yield { type: 'error', error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} failed`) };
            break;
        }
        case 'cancelled': { /* synthesized */ }
    }
    return;
}

To Reproduce

  1. Register a tool whose handler stores a failure with storeTaskResult(taskId, 'failed', { content: [...], isError: true }).
  2. Call it via client.experimental.tasks.requestStream(...) with the task option set.
  3. The terminal event is ProtocolError("Task <id> failed")

Expected behavior
requestStream should call tasks/result on failed the same way it does on completed, and yield whatever comes back: { type: 'result', result } for a stored Result, or { type: 'error', error } (with the real code/message/data preserved) if tasks/result returns a JSON-RPC error. cancelled can stay synthesized since nothing is stored for it.

Logs
N/A - deterministic from the code path above.

Additional context
This helper is shared between the client and server experimental tasks modules, so both ExperimentalClientTasks.requestStream and ExperimentalServerTasks.requestStream are affected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentgood first issueGood for newcomers - can be tackled without deep knowledge of the codebaseready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions