Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/adr/0001-stream-event-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# ADR 0001: Stream Event Protocol v1

- **Status:** Proposed (awaiting review on #27)
- **Date:** 2026-09-11
- **Deciders:** helsome/folio maintainers, contributor for #27

## Context

The Copilot answer path currently streams through an implicit `AgentEvent` protocol
(`packages/core/src/index.ts`): 8 event types
(`run_started / message_started / message_delta / message_completed / tool_started / tool_completed / run_completed / run_failed`)
carried over one IPC channel (`agent:event` in `apps/electron`). The envelope already has
`id / sessionId / runId / timestamp / sequence`, but the protocol lacks:

- a protocol version (no forward-compat contract);
- a monotonic `sequence` contract that all producers honor (idempotency / resume);
- an independent `cancelled` event (UI currently infers cancellation from `run_failed`);
- typed payloads for `tool_progress`, `citation_added`, and `status`.

Issue #27 asks for a stable, versioned streaming event protocol with cancel and
reconnect support.

## Decision

Introduce **Stream Event Protocol v1** as pure type/enum definitions in
`packages/core/src/stream-events.ts` (re-exported from `@finagent/core`). It is a
protocol-layer upgrade that coexists with `AgentEvent` during migration and
gradually replaces it.

### Envelope

```ts
interface StreamEventEnvelope<T extends StreamEventType> {
protocolVersion: 1; // forward-compat gate
runId: string;
messageId: string; // equals runId in v1; split later if one message spans runs
sequence: number; // monotonic per run; idempotency key = runId + messageId + sequence
type: T;
timestamp: string; // ISO 8601 UTC; display only, never identity
payload: StreamEventTypeToPayload[T];
}
```

### Event types (12)

`run_started · message_started · text_delta · tool_started · tool_progress · tool_result ·
citation_added · status · error · cancelled · message_completed · run_completed`

Key behavior change: `message_delta` (full-answer snapshot) becomes `text_delta`
(incremental, append-only text).

### Cross-cutting contracts

| Capability | Contract |
|---|---|
| Idempotency | Consumers dedupe on `runId + messageId + sequence`; replayed events never re-insert text, tool cards, or citations |
| Cancel | renderer `cancelRun` → runtime propagates → runtime **explicitly emits `cancelled`**; partial answer preserved |
| Reconnect | client reconnects with `lastSequence`; unconsumed gap is replayed (resume), consumed events are not re-applied |
| Final-state parity | UI `stopReason` and persisted `run.status` come from the same single final state in run-manager (aligns with #18) |
| Security (#19) | `status` never exposes chain-of-thought; tool payloads pass redaction before reaching the UI; renderer never executes model-returned code |

## Migration path

1. **Land types first (this ADR + `stream-events.ts`)**: zero runtime change, reviewable alone.
2. Runtime event loop (`run-manager`) emits the new typed events.
3. Transport upgrade (`kernelHost` + `preload`); renderer consumes the new protocol.
4. **Compat window**: keep `message_delta` as a "resume snapshot backfill" event —
transport sends a full snapshot once after reconnect, then only `text_delta` increments.

## Consequences

- **Positive:** versioned contract; deterministic idempotency; explicit cancellation
state; groundwork for reconnect/resume and for #21 immutable run manifests.
- **Negative:** dual event families during migration; consumers must handle both.
- **Open questions for reviewers:**
1. Resume data source: v1 keeps events in memory for the run's lifetime and defers
persisting an event log (ties into #21) — acceptable?
2. Is `status.phase` of `thinking / searching / working` sufficient?
3. Keep `messageId` merged with `runId` in v1 and split only when needed?
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { FinancialEvidenceEnvelope } from './financial-evidence.ts';

export type { SupportedLocale, LocalePreference } from './locale.ts';

// Stream Event Protocol v1 (issue #27, docs/adr/0001-stream-event-protocol.md)
export * from './stream-events.ts';

export interface Quote {
symbol: string;
/** Folio canonical instrument id when the quote was resolved through the catalog. */
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/stream-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Stream Event Protocol v1 — 类型层完整性测试
// 纯类型交付的自检:事件类型数量/无重复、payload 判别映射、envelope 构造约束。

import { describe, expect, it } from 'bun:test';
import {
STREAM_EVENT_PROTOCOL_VERSION,
STREAM_EVENT_TYPES,
type StreamEvent,
type StreamEventEnvelope,
type StreamEventType,
type StreamEventTypeToPayload,
} from './stream-events.ts';

describe('Stream Event Protocol v1', () => {
it('枚举 12 种协议事件类型且无重复', () => {
expect(STREAM_EVENT_TYPES).toHaveLength(12);
expect(new Set(STREAM_EVENT_TYPES).size).toBe(12);
});

it('每个事件类型都有对应的 typed payload(编译期覆盖)', () => {
// 编译期强制约束:所有 StreamEventType 必须在映射表中存在。
type Coverage = { [K in StreamEventType]: StreamEventTypeToPayload[K] };
const coverage: Coverage = {} as Coverage;
expect(coverage).toBeDefined();
});

it('protocol version 恒为 1', () => {
expect(STREAM_EVENT_PROTOCOL_VERSION).toBe(1);
});

it('envelope 按 type 判别 payload(编译期约束)', () => {
// 通过类型构造示例事件流;completion 事件可用新事件类型
const runStarted: StreamEvent = {
protocolVersion: 1,
runId: 'run-1',
messageId: 'run-1',
sequence: 1,
type: 'run_started',
timestamp: '2026-09-11T00:00:00.000Z',
payload: { input: 'show me AAPL.US', startedAt: '2026-09-11T00:00:00.000Z' },
};
const delta: StreamEvent = {
...runStarted,
protocolVersion: STREAM_EVENT_PROTOCOL_VERSION,
sequence: 2,
type: 'text_delta',
payload: { text: 'Apple ' },
};
const cancelled: StreamEvent = {
...runStarted,
protocolVersion: STREAM_EVENT_PROTOCOL_VERSION,
sequence: 9,
type: 'cancelled',
payload: { reason: 'user', partial: { text: 'Apple ' } },
};
const done: StreamEvent = {
...runStarted,
protocolVersion: STREAM_EVENT_PROTOCOL_VERSION,
sequence: 10,
type: 'run_completed',
payload: { stopReason: 'cancelled' },
};

const events: StreamEvent<StreamEventType>[] = [runStarted, delta, cancelled, done];
expect(events).toHaveLength(4);
expect(events[2].type).toBe('cancelled');
});
});
83 changes: 83 additions & 0 deletions packages/core/src/stream-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Stream Event Protocol v1
//
// 结构化流式事件协议的类型定义(issue #27)。
// 这是协议层的"纯类型 + 枚举"交付,无任何运行时行为变更。
// 设计文档:docs/adr/0001-stream-event-protocol.md
//
// 与现有 AgentEvent(同文件 index.ts)的关系:
// - AgentEvent 是内部 8 事件隐式协议;本模块是其协议化升级版(12 事件 + 版本 + 单调 seq)。
// - 迁移期间两者并存,AgentEvent 逐步被取代(见 ADR "Migration" 一节)。

export const STREAM_EVENT_PROTOCOL_VERSION = 1 as const;

export type StreamStatusPhase = 'thinking' | 'searching' | 'working';
export type StreamCancelReason = 'user' | 'budget' | 'runtime';
export type StreamStopReason = 'completed' | 'cancelled' | 'error' | 'budget';

/** 协议事件类型全集(12 种)。 */
export type StreamEventType =
| 'run_started'
| 'message_started'
| 'text_delta'
| 'tool_started'
| 'tool_progress'
| 'tool_result'
| 'citation_added'
| 'status'
| 'error'
| 'cancelled'
| 'message_completed'
| 'run_completed';

/** 类型 -> payload 映射。envelope.type 作为唯一判别字段,payload 不再重复 type。 */
export interface StreamEventTypeToPayload {
run_started: { input: string; startedAt: string };
message_started: Record<string, never>;
text_delta: { text: string };
tool_started: { callId: string; name: string; input?: unknown };
tool_progress: { callId: string; progress?: unknown };
tool_result: { callId: string; name: string; result: unknown };
citation_added: { citationId: string; sourceId: string };
status: { phase: StreamStatusPhase; detail?: string };
error: { code: string; message: string; retryable: boolean };
cancelled: { reason: StreamCancelReason; partial: { text: string } };
message_completed: Record<string, never>;
run_completed: { stopReason: StreamStopReason };
}

export type StreamEventPayload = StreamEventTypeToPayload[StreamEventType];

/**
* 统一事件信封。
* - 幂等键:runId + messageId + sequence。
* - sequence 为 run 内单调递增;reconnect 以它为游标(lastSequence 补发)。
* - timestamp 仅用于展示/排序,不作为身份。
*/
export interface StreamEventEnvelope<T extends StreamEventType = StreamEventType> {
protocolVersion: typeof STREAM_EVENT_PROTOCOL_VERSION;
runId: string;
/** v1 与 runId 相同;预留“一条 message 跨多次 run”时拆分。 */
messageId: string;
sequence: number;
type: T;
timestamp: string;
payload: StreamEventTypeToPayload[T];
}

export type StreamEvent<T extends StreamEventType = StreamEventType> = StreamEventEnvelope<T>;

/** 供完整性检查/测试用的枚举列表,必须与 StreamEventType 一一对应。 */
export const STREAM_EVENT_TYPES = [
'run_started',
'message_started',
'text_delta',
'tool_started',
'tool_progress',
'tool_result',
'citation_added',
'status',
'error',
'cancelled',
'message_completed',
'run_completed',
] as const satisfies readonly StreamEventType[];
Loading