From d611efe77ac6739a4de5a7ad31622fbe6679b86c Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 30 Sep 2026 23:34:53 +0200 Subject: [PATCH] feat(giellie): shared types and SSE codec Co-Authored-By: Claude Sonnet 5.5 --- src/lib/giellie-sse.test.ts | 29 ++++++++++++++++++++++++++ src/lib/giellie-sse.ts | 39 +++++++++++++++++++++++++++++++++++ src/lib/giellie-types.ts | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/lib/giellie-sse.test.ts create mode 100644 src/lib/giellie-sse.ts create mode 100644 src/lib/giellie-types.ts diff --git a/src/lib/giellie-sse.test.ts b/src/lib/giellie-sse.test.ts new file mode 100644 index 0000000..7876a75 --- /dev/null +++ b/src/lib/giellie-sse.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { encodeEvent, createEventParser, type GielieEvent } from "./giellie-sse"; + +describe("giellie SSE codec", () => { + it("round-trips events", () => { + const events: GielieEvent[] = [ + { type: "text", delta: "hel" }, + { type: "tool_start", id: "t1", name: "orders.get", input: { id: "x" } }, + { type: "done" }, + ]; + const parser = createEventParser(); + const out = parser.push(events.map(encodeEvent).join("")); + expect(out).toEqual(events); + }); + + it("buffers a frame split across chunks", () => { + const frame = encodeEvent({ type: "text", delta: "split me" }); + const parser = createEventParser(); + expect(parser.push(frame.slice(0, 10))).toEqual([]); + expect(parser.push(frame.slice(10))).toEqual([{ type: "text", delta: "split me" }]); + }); + + it("ignores malformed frames instead of throwing", () => { + const parser = createEventParser(); + expect(parser.push("data: {not json\n\n" + encodeEvent({ type: "done" }))).toEqual([ + { type: "done" }, + ]); + }); +}); diff --git a/src/lib/giellie-sse.ts b/src/lib/giellie-sse.ts new file mode 100644 index 0000000..30637b8 --- /dev/null +++ b/src/lib/giellie-sse.ts @@ -0,0 +1,39 @@ +import type { PendingAction } from "./giellie-types"; + +/** Events streamed from the server to the chat UI. */ +export type GielieEvent = + | { type: "text"; delta: string } + | { type: "tool_start"; id: string; name: string; input: Record } + | { type: "tool_result"; id: string; name: string; isError: boolean; preview: string } + | { type: "approval_required"; pending: PendingAction } + | { type: "title"; title: string } + | { type: "error"; message: string } + | { type: "done" }; + +export function encodeEvent(event: GielieEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +/** Incremental SSE parser: feed decoded text chunks, get complete events. */ +export function createEventParser() { + let buffer = ""; + return { + push(chunk: string): GielieEvent[] { + buffer += chunk; + const events: GielieEvent[] = []; + let idx: number; + while ((idx = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const line = frame.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + try { + events.push(JSON.parse(line.slice(6)) as GielieEvent); + } catch { + // Malformed frame — drop it; the stream keeps going. + } + } + return events; + }, + }; +} diff --git a/src/lib/giellie-types.ts b/src/lib/giellie-types.ts new file mode 100644 index 0000000..b893f5a --- /dev/null +++ b/src/lib/giellie-types.ts @@ -0,0 +1,41 @@ +/** + * Shared Giellie types (server + client). Message blocks follow the + * Anthropic content-block shape but are kept loose (`Block`) because + * MiniMax also returns `thinking` blocks that must round-trip untouched. + */ +export type Block = { type: string; [key: string]: unknown }; + +export interface StoredMessage { + role: "user" | "assistant"; + content: Block[]; + ts: string; +} + +export interface PendingToolUse { + id: string; + /** Dotted MCP name, e.g. `orders.update`. */ + name: string; + input: Record; + /** Email/PO sends — the trust toggle never auto-approves these. */ + confirmAlways: boolean; +} + +export interface PendingAction { + tool_uses: PendingToolUse[]; + /** tool_result blocks already produced this turn (read tools). */ + completed: Block[]; + /** Every tool_use id of the assistant turn, in original order. */ + order: string[]; +} + +export interface GielieChatSummary { + id: string; + title: string; + updated: string; +} + +export interface GielieChat extends GielieChatSummary { + messages: StoredMessage[]; + pending_action: PendingAction | null; + attachments: string[]; +}