From 63027e2584ad517541ac6597ac71c36557654694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E6=95=AC=E8=8D=A3?= <15920017+itjingrong@user.noreply.gitee.com> Date: Fri, 11 Sep 2026 12:12:56 +0800 Subject: [PATCH] feat(core): add Stream Event Protocol v1 types (ADR 0001) Introduce the pure-type foundation for the structured streaming event protocol (issue #27): versioned envelope, 12 typed events, and the idempotency/cancel/reconnect contracts as types. Zero runtime change. Adds ADR 0001 documenting context, decision, migration path and open questions for maintainer review. --- docs/adr/0001-stream-event-protocol.md | 79 +++++++++++++++++++++++ packages/core/src/index.ts | 3 + packages/core/src/stream-events.test.ts | 68 ++++++++++++++++++++ packages/core/src/stream-events.ts | 83 +++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 docs/adr/0001-stream-event-protocol.md create mode 100644 packages/core/src/stream-events.test.ts create mode 100644 packages/core/src/stream-events.ts diff --git a/docs/adr/0001-stream-event-protocol.md b/docs/adr/0001-stream-event-protocol.md new file mode 100644 index 0000000..9811c29 --- /dev/null +++ b/docs/adr/0001-stream-event-protocol.md @@ -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 { + 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? \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b749ac..24001b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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. */ diff --git a/packages/core/src/stream-events.test.ts b/packages/core/src/stream-events.test.ts new file mode 100644 index 0000000..c9f6110 --- /dev/null +++ b/packages/core/src/stream-events.test.ts @@ -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[] = [runStarted, delta, cancelled, done]; + expect(events).toHaveLength(4); + expect(events[2].type).toBe('cancelled'); + }); +}); \ No newline at end of file diff --git a/packages/core/src/stream-events.ts b/packages/core/src/stream-events.ts new file mode 100644 index 0000000..63b1e8e --- /dev/null +++ b/packages/core/src/stream-events.ts @@ -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; + 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; + run_completed: { stopReason: StreamStopReason }; +} + +export type StreamEventPayload = StreamEventTypeToPayload[StreamEventType]; + +/** + * 统一事件信封。 + * - 幂等键:runId + messageId + sequence。 + * - sequence 为 run 内单调递增;reconnect 以它为游标(lastSequence 补发)。 + * - timestamp 仅用于展示/排序,不作为身份。 + */ +export interface StreamEventEnvelope { + 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 = StreamEventEnvelope; + +/** 供完整性检查/测试用的枚举列表,必须与 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[]; \ No newline at end of file