feat(giellie): shared types and SSE codec

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-30 23:34:53 +02:00
parent 0eb9653359
commit d611efe77a
3 changed files with 109 additions and 0 deletions

View file

@ -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" },
]);
});
});

39
src/lib/giellie-sse.ts Normal file
View file

@ -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<string, unknown> }
| { 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;
},
};
}

41
src/lib/giellie-types.ts Normal file
View file

@ -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<string, unknown>;
/** 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[];
}