Skip to content

Commit c18cefc

Browse files
bloveclaude
andauthored
feat(chat): require events$ on Agent contract with structured AgentEvent union (#138)
* docs: events$ on Agent contract design Replace optional customEvents$ with required events$ carrying a discriminated union (AgentStateUpdateEvent | AgentCustomEvent). Codify invariant: state on signals, events on events$, no duplication. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: events\$ on Agent contract implementation plan 3 tasks: rewrite agent-event types + chat-side rewiring (single commit due to intermediate breakage), langgraph adapter translation, final verify + PR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(chat): require events$ on Agent contract with structured AgentEvent union Replaces optional customEvents$: Observable<AgentCustomEvent> with required events$: Observable<AgentEvent>. AgentEvent discriminates state_update from generic custom events. Codifies invariant: state on signals, events on events$, no duplication. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(langgraph): translate CustomStreamEvents into structured AgentEvent toAgent(ref) now emits events$: Observable<AgentEvent>. Translates state_update events into AgentStateUpdateEvent (with data: Record), all others into the structured AgentCustomEvent escape hatch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 891fcb7 commit c18cefc

15 files changed

Lines changed: 911 additions & 136 deletions

docs/superpowers/plans/2026-04-25-events-on-agent-contract.md

Lines changed: 523 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# `events$` on `Agent` Contract Design
2+
3+
## Goal
4+
5+
Replace the optional, free-form `customEvents$?: Observable<AgentCustomEvent>` on the `Agent` contract with a required, structured `events$: Observable<AgentEvent>` carrying a discriminated union. Codify the invariant: **state lives on signals, events live on `events$`, neither duplicates the other.**
6+
7+
## Motivation
8+
9+
The current `customEvents$` is the only event-shaped concern on the contract today. It is:
10+
11+
- **Optional** — adapters may omit it, forcing every consumer to null-check before subscribing.
12+
- **Free-form**`{ type: string; [key: string]: unknown }` lets any field name flow through, but provides no type-narrowing for known event types like `state_update`.
13+
- **Single example, no growth path** — the addition of more event-shaped concerns has been informally proposed (e.g., as part of broader course-correction discussions on AG-UI alignment). Without a structured union, each addition would either be a string-literal convention buried in handler code or a separate optional Observable on the contract.
14+
15+
Codifying `events$` as required + structured does three things:
16+
17+
1. **Removes optionality friction.** Every consumer can subscribe directly with no presence check.
18+
2. **Makes well-known event types type-safe.** The current `chat.component.ts` handler does `if (event.type === 'state_update') { ... }` and trusts the payload shape; with the structured union, TypeScript narrows the variant.
19+
3. **Establishes the duplication invariant.** State-bearing concerns (`messages`, `toolCalls`, `status`, `interrupt`, `subagents`, `state`, `history`) stay on signals. Events on `events$` carry only things that are not derivable from signals.
20+
21+
## Architecture
22+
23+
### Contract change
24+
25+
```ts
26+
// libs/chat/src/lib/agent/agent-event.ts (new file)
27+
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
28+
29+
/**
30+
* Render-state-store sync event. Adapters emit this when the runtime
31+
* publishes a state-snapshot intended for the chat library's render store
32+
* (used by generative UI and a2ui surfaces).
33+
*/
34+
export interface AgentStateUpdateEvent {
35+
readonly type: 'state_update';
36+
readonly data: Record<string, unknown>;
37+
}
38+
39+
/**
40+
* Escape hatch for runtime-specific or user-defined events that do not
41+
* (yet) have a well-known structured variant. `name` carries the runtime
42+
* event name; `data` carries the payload verbatim.
43+
*/
44+
export interface AgentCustomEvent {
45+
readonly type: 'custom';
46+
readonly name: string;
47+
readonly data: unknown;
48+
}
49+
50+
export type AgentEvent = AgentStateUpdateEvent | AgentCustomEvent;
51+
```
52+
53+
```ts
54+
// libs/chat/src/lib/agent/agent.ts (delta)
55+
import type { Observable } from 'rxjs';
56+
import type { AgentEvent } from './agent-event';
57+
58+
export interface Agent {
59+
// ...all existing signals unchanged...
60+
events$: Observable<AgentEvent>; // required; replaces customEvents$
61+
// ...submit, stop unchanged...
62+
}
63+
```
64+
65+
### Removed types
66+
67+
- `AgentCustomEvent` (the previous free-form `{type: string, [k]: unknown}`) is **deleted**. The new `AgentCustomEvent` (structured `{type: 'custom', name, data}`) reuses the symbol — semantically the escape-hatch case is what the old one always was, but typed.
68+
69+
The collision in name is intentional: the structured variant **is** the spiritual successor. Any consumer that imported `AgentCustomEvent` updates their usage from `event.type === 'foo'` (free) to `event.type === 'custom' && event.name === 'foo'` (structured).
70+
71+
### File renames
72+
73+
- `libs/chat/src/lib/agent/agent-custom-event.ts``agent-event.ts`
74+
- `libs/chat/src/lib/agent/agent-custom-event.spec.ts``agent-event.spec.ts`
75+
76+
### Required vs optional
77+
78+
Required. Adapters that have no event source pass `EMPTY` from RxJS:
79+
80+
```ts
81+
import { EMPTY } from 'rxjs';
82+
83+
const agent: Agent = {
84+
// ...
85+
events$: EMPTY,
86+
// ...
87+
};
88+
```
89+
90+
This is preferable to optionality because:
91+
- `EMPTY` is a one-line, free idiom for "this stream produces nothing".
92+
- Consumers never write `if (agent.events$) ...`.
93+
- Future event types added to the union benefit every adapter automatically; an adapter that wants to opt out of a specific event type just doesn't emit it.
94+
95+
### Adapter behavior
96+
97+
**LangGraph adapter (`libs/langgraph/src/lib/to-agent.ts`):**
98+
99+
The existing `buildCustomEvents$(ref)` helper translates LangGraph's `CustomStreamEvent[]` signal into the new `AgentEvent` stream. The translation:
100+
101+
```ts
102+
function toAgentEvent(e: CustomStreamEvent): AgentEvent {
103+
if (e.name === 'state_update' && isRecord(e.data)) {
104+
return { type: 'state_update', data: e.data };
105+
}
106+
return { type: 'custom', name: e.name, data: e.data };
107+
}
108+
109+
function isRecord(v: unknown): v is Record<string, unknown> {
110+
return typeof v === 'object' && v !== null && !Array.isArray(v);
111+
}
112+
```
113+
114+
The bridge subject + cursor pattern (effect-driven, append-only-array → Observable) is preserved. Rename the helper to `buildEvents$` to match the new contract field name.
115+
116+
**Mock helper (`libs/chat/src/lib/testing/mock-agent.ts`):**
117+
118+
```ts
119+
export interface MockAgentOptions {
120+
// ...other options unchanged...
121+
events$?: Observable<AgentEvent>; // optional input; defaults to EMPTY
122+
// (drop) customEvents$
123+
}
124+
125+
export function mockAgent(opts: MockAgentOptions = {}): MockAgent {
126+
// ...
127+
return {
128+
// ...existing fields...
129+
events$: opts.events$ ?? EMPTY,
130+
// ...
131+
};
132+
}
133+
```
134+
135+
**Conformance helper (`libs/chat/src/lib/testing/agent-conformance.ts`):**
136+
137+
Replace the conditional `if (agent.customEvents$ !== undefined) ...` block with an unconditional assertion:
138+
139+
```ts
140+
it('events$ is an Observable-like with .subscribe', () => {
141+
const agent = factory();
142+
expect(typeof agent.events$.subscribe).toBe('function');
143+
});
144+
```
145+
146+
### Consumer migration
147+
148+
Only one consumer in production: `libs/chat/src/lib/compositions/chat/chat.component.ts`. Today:
149+
150+
```ts
151+
const stream$ = agent.customEvents$;
152+
if (!stream$) return;
153+
stream$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
154+
if (event.type !== 'state_update') return;
155+
const data = event['data'];
156+
if (!data || typeof data !== 'object') return;
157+
// ...store.update(data as Record<string, unknown>);
158+
});
159+
```
160+
161+
After:
162+
163+
```ts
164+
agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
165+
if (event.type !== 'state_update') return;
166+
// event.data is Record<string, unknown> — narrowed by the discriminator
167+
const store = this.resolvedStore();
168+
if (!store) return;
169+
store.update(event.data);
170+
});
171+
```
172+
173+
Cleaner: no presence check, no untyped index access, no manual shape guard.
174+
175+
### Public API
176+
177+
`libs/chat/src/public-api.ts`:
178+
- Remove: `AgentCustomEvent` (old free-form) export
179+
- Add: `AgentEvent`, `AgentStateUpdateEvent`, `AgentCustomEvent` (new structured) exports
180+
181+
```ts
182+
export type {
183+
// ...other types unchanged...
184+
AgentEvent,
185+
AgentStateUpdateEvent,
186+
AgentCustomEvent, // now the structured escape-hatch variant
187+
} from './lib/agent';
188+
```
189+
190+
## What's deliberately NOT in the union
191+
192+
- **`tool_call_started/finished`** — already in `toolCalls: Signal<ToolCall[]>`; the array length and per-call `status` reflect lifecycle.
193+
- **`interrupt_raised/resolved`** — already in `interrupt?: Signal<AgentInterrupt | undefined>`; presence change reflects the lifecycle.
194+
- **`run_started/finished/errored`** — already in `status: Signal<AgentStatus>` and `error: Signal<unknown>`.
195+
- **`message_streamed`** — already in `messages: Signal<Message[]>`; partial deltas are reflected as in-place message content updates.
196+
- **`subagent_spawned`** — already in `subagents?: Signal<Map<string, Subagent>>`.
197+
198+
Adding events for any of these would violate the no-duplication invariant. If a consumer needs "happened-once" semantics for one of these (e.g., "fire a toast when a tool call finishes"), they derive it from the signal via an Angular `effect` that compares previous/current values.
199+
200+
## When to add new structured event types
201+
202+
Triggers:
203+
- A second adapter (AG-UI) reveals an event pattern that isn't state-shaped (i.e., not a snapshot or current-value of any concern), and is used by enough consumers to deserve type narrowing.
204+
- A chat-library convention emerges (similar to `state_update`) that crosses the runtime/library boundary.
205+
206+
Each addition is purely additive to the `AgentEvent` union — existing adapters that don't emit the new variant remain conformant.
207+
208+
## Out of Scope
209+
210+
- Backwards-compat alias `customEvents$` on `Agent`. Migration breaks compilation; consumers update at the same time.
211+
- Snapshot/replay semantics for late subscribers. State signals already carry current values; `events$` is delta-only.
212+
- Renaming `state_update` to a more chat-library-specific name. The convention is established and runtime-agnostic.
213+
- Adding non-event-shaped concerns (history, threads, etc.) to `events$`.
214+
- Renaming the bridge helper beyond `buildCustomEvents$``buildEvents$`.
215+
216+
## Risk
217+
218+
- **Breaking change to `customEvents$` shape and name.** Any external code subscribing to `agent.customEvents$` won't compile. Mitigated by the fact that only one production consumer exists today (`chat.component.ts`); the rest are tests and conformance helpers.
219+
- **`state_update` heuristic in LangGraph translation.** The `e.name === 'state_update'` branch is a string-literal convention — if a runtime emits `state_update` with non-`Record` data, we fall through to `custom`. Documented in the translator helper.

libs/chat/src/lib/agent/agent-custom-event.spec.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

libs/chat/src/lib/agent/agent-custom-event.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
2+
import { describe, it, expect } from 'vitest';
3+
import type {
4+
AgentEvent,
5+
AgentStateUpdateEvent,
6+
AgentCustomEvent,
7+
} from './agent-event';
8+
9+
describe('AgentEvent', () => {
10+
it('narrows AgentStateUpdateEvent by type discriminator', () => {
11+
const e: AgentEvent = { type: 'state_update', data: { foo: 1 } };
12+
if (e.type === 'state_update') {
13+
expect(e.data.foo).toBe(1);
14+
}
15+
});
16+
17+
it('narrows AgentCustomEvent by type discriminator', () => {
18+
const e: AgentEvent = { type: 'custom', name: 'tick', data: 42 };
19+
if (e.type === 'custom') {
20+
expect(e.name).toBe('tick');
21+
expect(e.data).toBe(42);
22+
}
23+
});
24+
25+
it('AgentStateUpdateEvent.data is Record-shaped', () => {
26+
const e: AgentStateUpdateEvent = { type: 'state_update', data: {} };
27+
expect(typeof e.data).toBe('object');
28+
});
29+
30+
it('AgentCustomEvent.data is unknown', () => {
31+
const e: AgentCustomEvent = { type: 'custom', name: 'x', data: null };
32+
expect(e.data).toBeNull();
33+
});
34+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
2+
3+
/**
4+
* Render-state-store sync event. Adapters emit this when the runtime
5+
* publishes a state-snapshot intended for the chat library's render store
6+
* (used by generative UI and a2ui surfaces).
7+
*/
8+
export interface AgentStateUpdateEvent {
9+
readonly type: 'state_update';
10+
readonly data: Record<string, unknown>;
11+
}
12+
13+
/**
14+
* Escape hatch for runtime-specific or user-defined events that do not
15+
* (yet) have a well-known structured variant. `name` carries the runtime
16+
* event name; `data` carries the payload verbatim.
17+
*/
18+
export interface AgentCustomEvent {
19+
readonly type: 'custom';
20+
readonly name: string;
21+
readonly data: unknown;
22+
}
23+
24+
/**
25+
* Discriminated union of events flowing on `Agent.events$`.
26+
*
27+
* Invariant: state lives on signals (`messages`, `status`, `toolCalls`,
28+
* `state`, `interrupt`, `subagents`, `history`); events on `events$`
29+
* carry only things that are not derivable from signals. New variants
30+
* are added purely additively when patterns prove necessary.
31+
*/
32+
export type AgentEvent = AgentStateUpdateEvent | AgentCustomEvent;

libs/chat/src/lib/agent/agent.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ToolCall } from './tool-call';
66
import type { AgentStatus } from './agent-status';
77
import type { AgentInterrupt } from './agent-interrupt';
88
import type { Subagent } from './subagent';
9-
import type { AgentCustomEvent } from './agent-custom-event';
9+
import type { AgentEvent } from './agent-event';
1010
import type { AgentSubmitInput, AgentSubmitOptions } from './agent-submit';
1111

1212
/**
@@ -15,9 +15,12 @@ import type { AgentSubmitInput, AgentSubmitOptions } from './agent-submit';
1515
* Implementations are produced by runtime adapters (e.g. a LangGraph or
1616
* AG-UI adapter) or by user code for custom backends.
1717
*
18-
* `interrupt`, `subagents`, and `customEvents$` are optional: runtimes that
19-
* do not support these concepts should leave them undefined, and primitives
20-
* that need them check presence and render a neutral fallback when absent.
18+
* `interrupt` and `subagents` are optional: runtimes that do not support these
19+
* concepts should leave them undefined, and primitives that need them check
20+
* presence and render a neutral fallback when absent.
21+
*
22+
* Invariant: state lives on signals; `events$` carries only things that are
23+
* not derivable from signals.
2124
*/
2225
export interface Agent {
2326
// Core state
@@ -33,7 +36,9 @@ export interface Agent {
3336
stop: () => Promise<void>;
3437

3538
// Extended (optional; absent when runtime does not support)
36-
interrupt?: Signal<AgentInterrupt | undefined>;
37-
subagents?: Signal<Map<string, Subagent>>;
38-
customEvents$?: Observable<AgentCustomEvent>;
39+
interrupt?: Signal<AgentInterrupt | undefined>;
40+
subagents?: Signal<Map<string, Subagent>>;
41+
42+
// Events stream (required; emit EMPTY if runtime produces no events)
43+
events$: Observable<AgentEvent>;
3944
}

libs/chat/src/lib/agent/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export type { AgentStatus } from './agent-status';
88
export type { AgentInterrupt } from './agent-interrupt';
99
export type { Subagent, SubagentStatus } from './subagent';
1010
export type { AgentSubmitInput, AgentSubmitOptions } from './agent-submit';
11-
export type { AgentCustomEvent } from './agent-custom-event';
11+
export type {
12+
AgentEvent,
13+
AgentStateUpdateEvent,
14+
AgentCustomEvent,
15+
} from './agent-event';
1216
export type { AgentCheckpoint } from './agent-checkpoint';
1317
export type { AgentWithHistory } from './agent-with-history';

0 commit comments

Comments
 (0)