From 6ad07c742edaf2090fc61d3398e78b6b6c32d14d Mon Sep 17 00:00:00 2001 From: Ross Date: Fri, 4 Sep 2026 13:40:35 -0700 Subject: [PATCH 1/2] feat(sdk): type turn.resumed and background.changed system events The broker emits `turn.resumed` when the agent resumes the last turn on its own (a background task finished) and `background.changed` while idle whenever the set of background tasks holding the devbox awake changes. Both previously classified as `unknown`. Adds the SystemEvent members, parsing, guards, re-exports in every module, and docs. Co-Authored-By: Claude Fable 5.1 --- sdk/AGENTS.md | 4 +- sdk/README.md | 27 ++++++++-- sdk/src/acp/connection.ts | 9 ++-- sdk/src/acp/index.ts | 7 +++ sdk/src/acp/timeline-event-guards.ts | 4 ++ sdk/src/claude/classify-claude-axon-event.ts | 5 +- sdk/src/claude/connection.ts | 4 +- sdk/src/claude/index.ts | 7 +++ sdk/src/claude/timeline-event-guards.ts | 4 ++ sdk/src/codex/timeline-event-guards.ts | 4 ++ sdk/src/shared/index.ts | 7 +++ sdk/src/shared/timeline-event-guards.test.ts | 41 ++++++++++++++ sdk/src/shared/timeline-event-guards.ts | 57 ++++++++++++++++++++ sdk/src/shared/timeline.test.ts | 56 +++++++++++++++++++ sdk/src/shared/timeline.ts | 27 ++++++++++ sdk/src/shared/types.ts | 36 ++++++++++++- 16 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 sdk/src/shared/timeline-event-guards.test.ts diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index f8b24e1..5afe0c9 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -263,7 +263,7 @@ events. | `acp_protocol` | `SessionUpdate \| unknown` | Known ACP protocol event (agent or client method) | | `claude_protocol` | `SDKMessage` | Known Claude protocol event | | `codex_protocol` | Typed app-server frame | Known Codex app-server event (narrow with `event.eventType` or the `isCodex*` guards) | -| `system` | `SystemEvent` | Broker system event (`turn.started`, `turn.completed`, `turn.failed`, `broker.error`) | +| `system` | `SystemEvent` | Broker system event (`turn.started`, `turn.resumed`, `turn.completed`, `turn.failed`, `broker.error`, `background.changed`) | | `unknown` | `null` | Anything else — inspect `axonEvent` for details | Every timeline event has `{ kind, data, axonEvent }` where `axonEvent` is the @@ -279,7 +279,7 @@ conn.onTimelineEvent((event) => { // event.data is SessionUpdate | unknown break; case "system": - // event.data is SystemEvent ({ type: "turn.started" | "turn.completed" | "turn.failed", turnId, ... }) + // event.data is SystemEvent ({ type: "turn.started" | "turn.resumed" | "turn.completed" | "turn.failed", turnId, ... } | { type: "background.changed", active }) break; case "unknown": // event.data is null — check event.axonEvent for raw data diff --git a/sdk/README.md b/sdk/README.md index b6be97a..81ece98 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -105,7 +105,7 @@ conn.onTimelineEvent((event) => { // Typed ACP payload — narrow further with event.eventType break; case "system": - // event.data: { type: "turn.started" | "turn.completed" | "turn.failed", turnId, ... } + // event.data: { type: "turn.started" | "turn.resumed" | "turn.completed" | "turn.failed", turnId, ... } | { type: "background.changed", active } break; case "unknown": break; @@ -233,7 +233,7 @@ conn.onTimelineEvent((event) => { } break; case "system": - // event.data: { type: "turn.started" | "turn.completed" | "turn.failed", turnId, ... } + // event.data: { type: "turn.started" | "turn.resumed" | "turn.completed" | "turn.failed", turnId, ... } | { type: "background.changed", active } break; case "unknown": break; @@ -703,7 +703,7 @@ conn.onTimelineEvent((event: ACPTimelineEvent) => { // Use isFromAgent(event) / isFromUser(event) to check direction (or event.axonEvent.origin directly) break; case "system": - // event.data is SystemEvent: { type: "turn.started", turnId } | { type: "turn.completed", turnId, stopReason? } | { type: "turn.failed", turnId, error, stopReason? } | { type: "broker.error", message } + // event.data is SystemEvent: { type: "turn.started" | "turn.resumed", turnId } | { type: "turn.completed", turnId, stopReason? } | { type: "turn.failed", turnId, error, stopReason? } | { type: "broker.error", message } | { type: "background.changed", active } break; case "unknown": // event.data is null — use axonEvent to identify and parse the event yourself @@ -752,7 +752,7 @@ conn.onTimelineEvent((event: CodexTimelineEvent) => { // the typed notification frame, "response" -> a correlated JSON-RPC response) break; case "system": - // event.data is SystemEvent (turn.started / turn.completed / turn.failed / broker.error) + // event.data is SystemEvent (turn.started / turn.resumed / turn.completed / turn.failed / broker.error / background.changed) break; case "unknown": // event.data is null — use axonEvent to identify and parse the event yourself @@ -919,11 +919,28 @@ Typed representation of recognized broker system events: ```typescript type SystemEvent = | { type: "turn.started"; turnId: string } + | { type: "turn.resumed"; turnId: string } | { type: "turn.completed"; turnId: string; stopReason?: string } | { type: "turn.failed"; turnId: string; error: string; stopReason?: string } - | { type: "broker.error"; message: string }; + | { type: "broker.error"; message: string } + | { type: "background.changed"; active: BackgroundTask[] }; + +interface BackgroundTask { + id: string; + kind: "command" | "agent" | "workflow"; +} ``` +`turn.resumed` is emitted when the agent resumes the last turn on its own, +typically because a background task it started has finished. It carries the +same `turnId` as that turn, and the resumed turn ends with another +`turn.completed`. + +`background.changed` is emitted while no turn is running, whenever the set of +background tasks holding the devbox awake differs from what was last reported. +`active` is the full set still running; an empty list means the agent's +background work is done. Task churn inside a turn is not reported. + `turn.failed` is emitted when the broker terminates an in-flight turn (for example, on a model error). The Axon stream layer also rejects any pending ACP JSON-RPC request with a JSON-RPC error (`code: -32000`, `message: error`), diff --git a/sdk/src/acp/connection.ts b/sdk/src/acp/connection.ts index 3efc5bf..a735077 100644 --- a/sdk/src/acp/connection.ts +++ b/sdk/src/acp/connection.ts @@ -434,8 +434,8 @@ export class ACPAxonConnection { * * Every Axon event on the channel is classified into one of: * - `acp_protocol` — a known ACP protocol event (agent or client method) - * - `system` — a broker system event (`turn.started`, `turn.completed`, - * `turn.failed`, `broker.error`) + * - `system` — a broker system event (`turn.started`, `turn.resumed`, + * `turn.completed`, `turn.failed`, `broker.error`, `background.changed`) * - `unknown` — anything else * * For a pull-based alternative, see {@link receiveTimelineEvents}. @@ -606,8 +606,9 @@ export class ACPAxonConnection { * Classifies a raw Axon event into an {@link ACPTimelineEvent}. * * Classification rules: - * 1. `SYSTEM_EVENT` with `turn.started` / `turn.completed` / `turn.failed` / - * `broker.error` -> `system` + * 1. `SYSTEM_EVENT` with a recognized type (`turn.started`, `turn.resumed`, + * `turn.completed`, `turn.failed`, `broker.error`, `background.changed`, + * ...) -> `system` * 2. Known ACP protocol `event_type` (agent or client method) -> `acp_protocol` * 3. Everything else -> `unknown` * diff --git a/sdk/src/acp/index.ts b/sdk/src/acp/index.ts index 270764a..0dcedf8 100644 --- a/sdk/src/acp/index.ts +++ b/sdk/src/acp/index.ts @@ -72,6 +72,9 @@ export { tryParseSystemEvent, tryParseTimelinePayload } from "../shared/timeline export type { AxonEventListener, AxonEventView, + BackgroundChangedEvent, + BackgroundTask, + BackgroundTaskKind, BaseConnectionOptions, CustomTimelineEvent, SystemEvent, @@ -126,12 +129,14 @@ export { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, ElicitationCompleteTimelineEvent, ElicitationTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "./timeline-event-guards.js"; export { @@ -139,6 +144,7 @@ export { isACPProtocolEvent, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isDevboxLifecycleEvent, isElicitationCompleteEvent, @@ -151,6 +157,7 @@ export { isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "./timeline-event-guards.js"; diff --git a/sdk/src/acp/timeline-event-guards.ts b/sdk/src/acp/timeline-event-guards.ts index 1a1e2ee..377c06c 100644 --- a/sdk/src/acp/timeline-event-guards.ts +++ b/sdk/src/acp/timeline-event-guards.ts @@ -40,21 +40,25 @@ import type { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "../shared/timeline-event-guards.js"; export { createCustomEventGuard, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isDevboxLifecycleEvent, isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "../shared/timeline-event-guards.js"; diff --git a/sdk/src/claude/classify-claude-axon-event.ts b/sdk/src/claude/classify-claude-axon-event.ts index 34557c2..addd569 100644 --- a/sdk/src/claude/classify-claude-axon-event.ts +++ b/sdk/src/claude/classify-claude-axon-event.ts @@ -21,8 +21,9 @@ export function isClaudeProtocolEventType(eventType: string): boolean { * Classifies a raw Axon event into a {@link ClaudeTimelineEvent}. * * Classification rules: - * 1. `SYSTEM_EVENT` with `turn.started` / `turn.completed` / `turn.failed` / - * `broker.error` -> `system` + * 1. `SYSTEM_EVENT` with a recognized type (`turn.started`, `turn.resumed`, + * `turn.completed`, `turn.failed`, `broker.error`, `background.changed`, + * ...) -> `system` * 2. Known Claude protocol `event_type` -> `claude_protocol` with `eventType` discriminator * 3. Everything else -> `unknown` * diff --git a/sdk/src/claude/connection.ts b/sdk/src/claude/connection.ts index 1accc54..bbd6ff3 100644 --- a/sdk/src/claude/connection.ts +++ b/sdk/src/claude/connection.ts @@ -476,8 +476,8 @@ export class ClaudeAxonConnection { * * Every Axon event on the channel is classified into one of: * - `claude_protocol` — a known Claude protocol event (user or agent message) - * - `system` — a broker system event (`turn.started`, `turn.completed`, - * `turn.failed`, `broker.error`) + * - `system` — a broker system event (`turn.started`, `turn.resumed`, + * `turn.completed`, `turn.failed`, `broker.error`, `background.changed`) * - `unknown` — anything else * * For a pull-based alternative, see {@link receiveTimelineEvents}. diff --git a/sdk/src/claude/index.ts b/sdk/src/claude/index.ts index bd53588..ca08b1b 100644 --- a/sdk/src/claude/index.ts +++ b/sdk/src/claude/index.ts @@ -74,6 +74,9 @@ export { tryParseSystemEvent, tryParseTimelinePayload } from "../shared/timeline export type { AxonEventListener, AxonEventView, + BackgroundChangedEvent, + BackgroundTask, + BackgroundTaskKind, BaseConnectionOptions, CustomTimelineEvent, SystemEvent, @@ -100,16 +103,19 @@ export { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "./timeline-event-guards.js"; export { createCustomEventGuard, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isClaudeAssistantEvent, isClaudeAssistantTextEvent, @@ -123,6 +129,7 @@ export { isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "./timeline-event-guards.js"; diff --git a/sdk/src/claude/timeline-event-guards.ts b/sdk/src/claude/timeline-event-guards.ts index c69760c..b64b60d 100644 --- a/sdk/src/claude/timeline-event-guards.ts +++ b/sdk/src/claude/timeline-event-guards.ts @@ -40,21 +40,25 @@ import type { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "../shared/timeline-event-guards.js"; export { createCustomEventGuard, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isDevboxLifecycleEvent, isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "../shared/timeline-event-guards.js"; diff --git a/sdk/src/codex/timeline-event-guards.ts b/sdk/src/codex/timeline-event-guards.ts index cd11dce..cd2b2ea 100644 --- a/sdk/src/codex/timeline-event-guards.ts +++ b/sdk/src/codex/timeline-event-guards.ts @@ -22,21 +22,25 @@ import type { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "../shared/timeline-event-guards.js"; export { createCustomEventGuard, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isDevboxLifecycleEvent, isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "../shared/timeline-event-guards.js"; diff --git a/sdk/src/shared/index.ts b/sdk/src/shared/index.ts index feab4ac..9159f03 100644 --- a/sdk/src/shared/index.ts +++ b/sdk/src/shared/index.ts @@ -73,21 +73,25 @@ export { export type { AgentErrorTimelineEvent, AgentLogTimelineEvent, + BackgroundChangedTimelineEvent, BrokerErrorTimelineEvent, DevboxLifecycleTimelineEvent, TurnCompletedTimelineEvent, TurnFailedTimelineEvent, + TurnResumedTimelineEvent, TurnStartedTimelineEvent, } from "./timeline-event-guards.js"; export { createCustomEventGuard, isAgentErrorEvent, isAgentLogEvent, + isBackgroundChangedEvent, isBrokerErrorEvent, isDevboxLifecycleEvent, isSystemTimelineEvent, isTurnCompletedEvent, isTurnFailedEvent, + isTurnResumedEvent, isTurnStartedEvent, isUnknownTimelineEvent, } from "./timeline-event-guards.js"; @@ -99,6 +103,9 @@ export type { AgentLogType, AxonEventListener, AxonEventView, + BackgroundChangedEvent, + BackgroundTask, + BackgroundTaskKind, BaseConnectionOptions, BaseTimelineEvent, CustomTimelineEvent, diff --git a/sdk/src/shared/timeline-event-guards.test.ts b/sdk/src/shared/timeline-event-guards.test.ts new file mode 100644 index 0000000..64f6aeb --- /dev/null +++ b/sdk/src/shared/timeline-event-guards.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { makeFullAxonEvent as makeAxonEvent } from "../__test-utils__/mock-axon.js"; +import { isBackgroundChangedEvent, isTurnResumedEvent } from "./timeline-event-guards.js"; +import type { BaseTimelineEvent, SystemEvent } from "./types.js"; + +function systemEvent(data: SystemEvent): BaseTimelineEvent { + return { + kind: "system", + data, + axonEvent: makeAxonEvent({ event_type: data.type, origin: "SYSTEM_EVENT" }), + } as BaseTimelineEvent; +} + +describe("isTurnResumedEvent", () => { + it("narrows turn.resumed and rejects other system events", () => { + expect(isTurnResumedEvent(systemEvent({ type: "turn.resumed", turnId: "t-1" }))).toBe(true); + expect(isTurnResumedEvent(systemEvent({ type: "turn.started", turnId: "t-1" }))).toBe(false); + }); +}); + +describe("isBackgroundChangedEvent", () => { + it("narrows background.changed and rejects other system events", () => { + expect( + isBackgroundChangedEvent( + systemEvent({ type: "background.changed", active: [{ id: "a", kind: "agent" }] }), + ), + ).toBe(true); + expect(isBackgroundChangedEvent(systemEvent({ type: "turn.resumed", turnId: "t-1" }))).toBe( + false, + ); + }); + + it("rejects non-system events", () => { + const ev = { + kind: "unknown", + data: { type: "background.changed" }, + axonEvent: makeAxonEvent({ event_type: "background.changed" }), + } as BaseTimelineEvent; + expect(isBackgroundChangedEvent(ev)).toBe(false); + }); +}); diff --git a/sdk/src/shared/timeline-event-guards.ts b/sdk/src/shared/timeline-event-guards.ts index c17e4e6..c18b953 100644 --- a/sdk/src/shared/timeline-event-guards.ts +++ b/sdk/src/shared/timeline-event-guards.ts @@ -11,6 +11,7 @@ import { SYSTEM_EVENT_TYPES } from "./timeline.js"; import type { AgentErrorEvent, AgentLogEvent, + BackgroundChangedEvent, BaseTimelineEvent, CustomTimelineEvent, DevboxLifecycleKind, @@ -204,6 +205,62 @@ export function isAgentLogEvent(event: BaseTimelineEvent): event is AgentLogTime ); } +// --------------------------------------------------------------------------- +// Turn resumed guard +// --------------------------------------------------------------------------- + +/** + * Narrowed type for a `turn.resumed` system event. + * @category Timeline + */ +export type TurnResumedTimelineEvent = SystemTimelineEvent & { + data: { type: "turn.resumed"; turnId: string }; +}; + +/** + * Type guard for `turn.resumed` system events: the agent resumed the last + * turn on its own (for example, when a background task finished) and will + * end it with another `turn.completed`. + * + * @param event - The timeline event to test. + * @returns `true` if `event` is a {@link TurnResumedTimelineEvent}. + * @category Timeline + */ +export function isTurnResumedEvent(event: BaseTimelineEvent): event is TurnResumedTimelineEvent { + return ( + event.kind === "system" && + (event.data as { type?: string }).type === SYSTEM_EVENT_TYPES.TURN_RESUMED + ); +} + +// --------------------------------------------------------------------------- +// Background changed guard +// --------------------------------------------------------------------------- + +/** + * Narrowed type for a `background.changed` system event. + * @category Timeline + */ +export type BackgroundChangedTimelineEvent = SystemTimelineEvent & { + data: BackgroundChangedEvent; +}; + +/** + * Type guard for `background.changed` system events. + * + * @param event - The timeline event to test. + * @returns `true` if `event` is a {@link BackgroundChangedTimelineEvent}. + * @category Timeline + */ +export function isBackgroundChangedEvent( + event: BaseTimelineEvent, +): event is BackgroundChangedTimelineEvent { + return ( + event.kind === "system" && + (event.data as { type?: string }).type === SYSTEM_EVENT_TYPES.BACKGROUND_CHANGED + ); +} + // --------------------------------------------------------------------------- // Unknown event guard // --------------------------------------------------------------------------- diff --git a/sdk/src/shared/timeline.test.ts b/sdk/src/shared/timeline.test.ts index 1af0609..480303b 100644 --- a/sdk/src/shared/timeline.test.ts +++ b/sdk/src/shared/timeline.test.ts @@ -332,6 +332,62 @@ describe("tryParseSystemEvent", () => { expect(tryParseSystemEvent(ev)).toBeNull(); warnSpy.mockRestore(); }); + + it("parses turn.resumed like turn.started", () => { + const ev = makeAxonEvent({ + event_type: "turn.resumed", + payload: JSON.stringify({ turn_id: "t-1" }), + }); + expect(tryParseSystemEvent(ev)).toEqual({ type: "turn.resumed", turnId: "t-1" }); + }); + + it("parses background.changed with active tasks", () => { + const ev = makeAxonEvent({ + event_type: "background.changed", + payload: JSON.stringify({ + active: [ + { id: "cmd-1", kind: "command" }, + { id: "agent-1", kind: "agent" }, + ], + }), + }); + expect(tryParseSystemEvent(ev)).toEqual({ + type: "background.changed", + active: [ + { id: "cmd-1", kind: "command" }, + { id: "agent-1", kind: "agent" }, + ], + }); + }); + + it("parses background.changed with an empty list", () => { + const ev = makeAxonEvent({ + event_type: "background.changed", + payload: JSON.stringify({ active: [] }), + }); + expect(tryParseSystemEvent(ev)).toEqual({ type: "background.changed", active: [] }); + }); + + it("background.changed keeps unknown kinds and skips malformed entries", () => { + const ev = makeAxonEvent({ + event_type: "background.changed", + payload: JSON.stringify({ + active: [{ id: "x", kind: "future" }, { id: 42, kind: "command" }, "junk", null], + }), + }); + expect(tryParseSystemEvent(ev)).toEqual({ + type: "background.changed", + active: [{ id: "x", kind: "future" }], + }); + }); + + it("returns null for background.changed without an active array", () => { + const ev = makeAxonEvent({ + event_type: "background.changed", + payload: JSON.stringify({}), + }); + expect(tryParseSystemEvent(ev)).toBeNull(); + }); }); describe("isTurnFailedAxonEvent", () => { diff --git a/sdk/src/shared/timeline.ts b/sdk/src/shared/timeline.ts index 3bfa50e..64474a5 100644 --- a/sdk/src/shared/timeline.ts +++ b/sdk/src/shared/timeline.ts @@ -4,7 +4,10 @@ import type { AxonEventView } from "@runloop/api-client/resources/axons"; import { SYSTEM_EVENT_ORIGIN } from "./errors/system-error.js"; +import { isNonNullObject } from "./structural-guards.js"; import type { + BackgroundTask, + BackgroundTaskKind, DevboxLifecycleKind, SystemEvent, SystemTimelineEvent, @@ -23,6 +26,7 @@ import type { */ export const SYSTEM_EVENT_TYPES = { TURN_STARTED: "turn.started", + TURN_RESUMED: "turn.resumed", TURN_COMPLETED: "turn.completed", TURN_FAILED: "turn.failed", BROKER_ERROR: "broker.error", @@ -32,6 +36,7 @@ export const SYSTEM_EVENT_TYPES = { DEVBOX_FAILED: "devbox.failed", AGENT_ERROR: "agent.error", AGENT_LOG: "agent.log", + BACKGROUND_CHANGED: "background.changed", } as const; /** Set of all recognized system event type strings for O(1) lookup. */ @@ -101,6 +106,10 @@ interface AgentLogPayload { message?: string; } +interface BackgroundChangedPayload { + active?: unknown; +} + const DEVBOX_PREFIX = "devbox."; /** Set of recognized devbox lifecycle kinds for validation. */ @@ -150,6 +159,7 @@ export function tryParseTimelinePayload(event: { export function tryParseSystemEvent(ev: AxonEventView): SystemEvent | null { if ( ev.event_type === SYSTEM_EVENT_TYPES.TURN_STARTED || + ev.event_type === SYSTEM_EVENT_TYPES.TURN_RESUMED || ev.event_type === SYSTEM_EVENT_TYPES.TURN_COMPLETED ) { const parsed = tryParseTimelinePayload({ @@ -160,6 +170,9 @@ export function tryParseSystemEvent(ev: AxonEventView): SystemEvent | null { if (ev.event_type === SYSTEM_EVENT_TYPES.TURN_STARTED) { return { type: "turn.started", turnId }; } + if (ev.event_type === SYSTEM_EVENT_TYPES.TURN_RESUMED) { + return { type: "turn.resumed", turnId }; + } return { type: "turn.completed", turnId, @@ -224,6 +237,20 @@ export function tryParseSystemEvent(ev: AxonEventView): SystemEvent | null { }; } + if (ev.event_type === SYSTEM_EVENT_TYPES.BACKGROUND_CHANGED) { + const parsed = tryParseTimelinePayload({ axonEvent: ev }); + if (!parsed || !Array.isArray(parsed.active)) return null; + const active: BackgroundTask[] = []; + for (const entry of parsed.active) { + if (!isNonNullObject(entry)) continue; + const { id, kind } = entry as { id?: unknown; kind?: unknown }; + if (typeof id !== "string" || typeof kind !== "string") continue; + // Kinds the SDK does not know yet pass through as opaque strings. + active.push({ id, kind: kind as BackgroundTaskKind }); + } + return { type: "background.changed", active }; + } + return null; } diff --git a/sdk/src/shared/types.ts b/sdk/src/shared/types.ts index 9a32e8f..205bd3b 100644 --- a/sdk/src/shared/types.ts +++ b/sdk/src/shared/types.ts @@ -66,14 +66,48 @@ export interface AgentLogEvent { message: string; } +/** + * What a background task is: a shell command the agent left running + * (`command`), a sub-agent (`agent`), or a multi-step workflow (`workflow`). + * The broker may add kinds; treat unknown strings as opaque. + * + * @category Timeline + */ +export type BackgroundTaskKind = "command" | "agent" | "workflow"; + +/** + * One background task the broker is holding the devbox awake for. + * + * @category Timeline + */ +export interface BackgroundTask { + id: string; + kind: BackgroundTaskKind; +} + +/** + * Background work changed while the broker was idle. `active` is the full + * set of tasks still running after the turn ended; an empty list means the + * agent's background work has finished and nothing is holding the devbox + * awake. Not emitted during a turn. + * + * @category Timeline + */ +export interface BackgroundChangedEvent { + type: "background.changed"; + active: BackgroundTask[]; +} + export type SystemEvent = | { type: "turn.started"; turnId: string } + | { type: "turn.resumed"; turnId: string } | { type: "turn.completed"; turnId: string; stopReason?: string } | { type: "turn.failed"; turnId: string; error: string; stopReason?: string } | { type: "broker.error"; message: string } | DevboxLifecycleEvent | AgentErrorEvent - | AgentLogEvent; + | AgentLogEvent + | BackgroundChangedEvent; /** * Common shape shared by every timeline event variant. From 8109ed50989b222d9198b3bc5afdafeff4c5cfac Mon Sep 17 00:00:00 2001 From: Ross Date: Fri, 4 Sep 2026 13:55:28 -0700 Subject: [PATCH 2/2] fix(sdk): make BackgroundTaskKind an open string union The parser passes unknown kinds through, so the type must say so or an exhaustive switch typechecks and misses at runtime. Co-Authored-By: Claude Fable 5.1 --- sdk/README.md | 2 +- sdk/src/shared/timeline.ts | 5 ++--- sdk/src/shared/types.ts | 5 +++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/README.md b/sdk/README.md index 81ece98..540534f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -927,7 +927,7 @@ type SystemEvent = interface BackgroundTask { id: string; - kind: "command" | "agent" | "workflow"; + kind: "command" | "agent" | "workflow" | (string & {}); // open: the broker may add kinds } ``` diff --git a/sdk/src/shared/timeline.ts b/sdk/src/shared/timeline.ts index 64474a5..4743e46 100644 --- a/sdk/src/shared/timeline.ts +++ b/sdk/src/shared/timeline.ts @@ -7,7 +7,6 @@ import { SYSTEM_EVENT_ORIGIN } from "./errors/system-error.js"; import { isNonNullObject } from "./structural-guards.js"; import type { BackgroundTask, - BackgroundTaskKind, DevboxLifecycleKind, SystemEvent, SystemTimelineEvent, @@ -245,8 +244,8 @@ export function tryParseSystemEvent(ev: AxonEventView): SystemEvent | null { if (!isNonNullObject(entry)) continue; const { id, kind } = entry as { id?: unknown; kind?: unknown }; if (typeof id !== "string" || typeof kind !== "string") continue; - // Kinds the SDK does not know yet pass through as opaque strings. - active.push({ id, kind: kind as BackgroundTaskKind }); + // Kinds the SDK does not know yet pass through; the type is open. + active.push({ id, kind }); } return { type: "background.changed", active }; } diff --git a/sdk/src/shared/types.ts b/sdk/src/shared/types.ts index 205bd3b..1e2a8d0 100644 --- a/sdk/src/shared/types.ts +++ b/sdk/src/shared/types.ts @@ -69,11 +69,12 @@ export interface AgentLogEvent { /** * What a background task is: a shell command the agent left running * (`command`), a sub-agent (`agent`), or a multi-step workflow (`workflow`). - * The broker may add kinds; treat unknown strings as opaque. + * The set is open: the broker may add kinds, and the parser passes them + * through, so handle the string case rather than switching exhaustively. * * @category Timeline */ -export type BackgroundTaskKind = "command" | "agent" | "workflow"; +export type BackgroundTaskKind = "command" | "agent" | "workflow" | (string & {}); /** * One background task the broker is holding the devbox awake for.