Show and stop agent background work (#6220)

* feat(server): add an in-memory store for agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): report background tasks as background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): track agent background work in the agent manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(protocol): add agent background work messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): serve and push agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client): expose agent background work on agent handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): document agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background shells in background work end to end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): prove internal background work was emitted before checking globals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): describe background work resync and feature gating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
fxlelouarn
2026-11-06 19:49:49 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Mohamed Boudra
parent c8bd4717c9
commit 77798b2e46
99 changed files with 5681 additions and 149 deletions
@@ -6,6 +6,30 @@ import {
} from "./agent-state-bucket.js";
describe("deriveAgentStateBucket", () => {
it("shows idle agents with background work as running while preserving attention precedence", () => {
const backgroundWork = { count: 1 };
expect(
deriveAgentStateBucket({
status: "idle",
backgroundWork,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe("running");
expect(
deriveAgentStateBucket({ status: "idle", backgroundWork, attentionReason: "permission" }),
).toBe("needs_input");
expect(deriveAgentStateBucket({ status: "error", backgroundWork })).toBe("running");
expect(
deriveAgentStateBucket({
status: "error",
backgroundWork,
requiresAttention: true,
attentionReason: "error",
}),
).toBe("failed");
expect(deriveAgentStateBucket({ status: "idle", backgroundWork: { count: 0 } })).toBe("done");
});
it("prioritizes pending permissions as needs_input", () => {
expect(
deriveAgentStateBucket({
+3 -2
View File
@@ -9,6 +9,7 @@ export interface AgentStateBucketInput {
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentAttentionReason;
backgroundWork?: { count: number } | null;
}
const WORKSPACE_STATE_BUCKET_PRIORITY = {
@@ -32,7 +33,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS
if (hasUnreadError(input)) {
return "failed";
}
if (input.status === "running") {
if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) {
return "running";
}
if (input.requiresAttention) {
@@ -52,7 +53,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number {
if (hasUnreadError(input)) {
return 1;
}
if (input.status === "running") {
if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) {
return 2;
}
if (input.status === "initializing") {
@@ -0,0 +1,103 @@
import { describe, expect, test } from "vitest";
import {
ServerInfoStatusPayloadSchema,
SessionEventSubscriptionSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "./messages.js";
const ITEM = {
id: "bash-1",
kind: "shell",
description: "sleep 20",
startedAt: "2026-10-06T10:00:00.000Z",
};
describe("agent background work protocol", () => {
test("accepts a list request and its response", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.background_work.list.request",
agentId: "agent-1",
requestId: "request-1",
}),
).toMatchObject({ agentId: "agent-1", requestId: "request-1" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.list.response",
payload: { requestId: "request-1", agentId: "agent-1", items: [ITEM], error: null },
}),
).toMatchObject({ payload: { items: [ITEM] } });
});
test("accepts an update with or without a subscription id", () => {
for (const payload of [
{ agentId: "agent-1", items: [] },
{ subscriptionId: "events-1", agentId: "agent-1", items: [ITEM] },
]) {
expect(
SessionOutboundMessageSchema.parse({ type: "agent.background_work.update", payload }),
).toMatchObject({ payload });
}
});
test("keeps kind an open string so new kinds parse on old clients", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "agent-1", items: [{ ...ITEM, kind: "future-kind" }] },
}),
).toMatchObject({ payload: { items: [{ kind: "future-kind" }] } });
});
test("preserves an optional command while accepting older items", () => {
const withCommand = { ...ITEM, description: "Watch build", command: "tail -f build.log" };
const parsed = SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "agent-1", items: [ITEM, withCommand] },
});
expect(parsed).toMatchObject({ payload: { items: [ITEM, withCommand] } });
});
test("is a subscribable event", () => {
expect(SessionEventSubscriptionSchema.parse("agent.background_work.update")).toBe(
"agent.background_work.update",
);
});
test("keeps the feature optional for older server info payloads", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {},
}).features?.agentBackgroundWork,
).toBeUndefined();
});
});
test("stop requests carry an inventory token and preserve legacy item parsing", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.background_work.stop.request",
agentId: "a",
itemId: "x",
stopToken: "token",
requestId: "r",
}),
).toMatchObject({ stopToken: "token" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.stop.response",
payload: { requestId: "r", agentId: "a", itemId: "x", error: null },
}),
).toMatchObject({ payload: { error: null } });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.background_work.update",
payload: { agentId: "a", items: [ITEM] },
}),
).toMatchObject({ payload: { items: [ITEM] } });
});
+71
View File
@@ -873,6 +873,7 @@ export const AgentSnapshotPayloadSchema = z.object({
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
status: AgentStatusSchema,
backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(),
activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(),
capabilities: AgentCapabilityFlagsSchema,
currentModeId: z.string().nullable(),
@@ -907,6 +908,7 @@ export const AgentListItemPayloadSchema = z.object({
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
status: AgentStatusSchema,
backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(),
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -2001,6 +2003,20 @@ export const ProviderSubagentListRequestMessageSchema = z.object({
requestId: z.string(),
});
export const AgentBackgroundWorkListRequestMessageSchema = z.object({
type: z.literal("agent.background_work.list.request"),
agentId: z.string(),
requestId: z.string(),
});
export const AgentBackgroundWorkStopRequestMessageSchema = z.object({
type: z.literal("agent.background_work.stop.request"),
agentId: z.string(),
itemId: z.string(),
stopToken: z.string(),
requestId: z.string(),
});
export const ProviderSubagentTimelineRequestMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.request"),
parentAgentId: z.string(),
@@ -3287,6 +3303,7 @@ export const SessionEventSubscriptionSchema = z.enum([
"script_status_update",
"workspace_setup_progress",
"agent.provider_subagents.update",
"agent.background_work.update",
"terminal_attention_required",
"status.server_info",
"status.daemon_config_changed",
@@ -3436,6 +3453,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AgentTimelineSearchRequestMessageSchema,
AgentTimelineListPromptsRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
AgentBackgroundWorkListRequestMessageSchema,
AgentBackgroundWorkStopRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
SetAgentTimelineSubscriptionRequestMessageSchema,
AgentForkContextRequestMessageSchema,
@@ -3863,6 +3882,10 @@ export const ServerInfoStatusPayloadSchema = z
agentForkContextCursor: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
agentBackgroundWork: z.boolean().optional(),
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
agentBackgroundWorkStop: z.boolean().optional(),
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field.
projectedSubagentTimeline: z.boolean().optional(),
wholeTurnTimeline: z.boolean().optional(),
@@ -4953,6 +4976,51 @@ export const ProviderSubagentListResponseMessageSchema = z.object({
}),
});
export const AgentBackgroundWorkItemSchema = z.object({
id: z.string(),
// Open string: known kinds include "shell" and "monitor".
// Clients treat unknown values as "other".
kind: z.string(),
description: z.string().nullable(),
command: z.string().optional(),
startedAt: z.string(),
canStop: z.boolean().optional(),
stopToken: z.string().optional(),
});
export type AgentBackgroundWorkItemPayload = z.infer<typeof AgentBackgroundWorkItemSchema>;
export const AgentBackgroundWorkListResponseMessageSchema = z.object({
type: z.literal("agent.background_work.list.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
items: z.array(AgentBackgroundWorkItemSchema),
error: z.string().nullable(),
}),
});
export const AgentBackgroundWorkStopResponseMessageSchema = z.object({
type: z.literal("agent.background_work.stop.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
itemId: z.string(),
error: z.string().nullable(),
}),
});
// Event push delivered through session.events.set_subscription; it has no .request/.response pair.
// Each update replaces the agent's whole list.
export const AgentBackgroundWorkUpdateMessageSchema = z.object({
type: z.literal("agent.background_work.update"),
payload: z.object({
subscriptionId: z.string().optional(),
agentId: z.string(),
items: z.array(AgentBackgroundWorkItemSchema),
}),
});
export const ProviderSubagentTimelineResponseMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.response"),
payload: z.object({
@@ -7212,6 +7280,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
AgentBackgroundWorkListResponseMessageSchema,
AgentBackgroundWorkStopResponseMessageSchema,
AgentBackgroundWorkUpdateMessageSchema,
SetAgentTimelineSubscriptionResponseMessageSchema,
AgentAttentionRequiredMessageSchema,
AgentForkContextResponseMessageSchema,