diff --git a/studio/src/features/agent/types.ts b/studio/src/features/agent/types.ts new file mode 100644 index 0000000000..93b2e41a23 --- /dev/null +++ b/studio/src/features/agent/types.ts @@ -0,0 +1,454 @@ +// ── Sessions ──────────────────────────────────────────────────────────────── + +export interface AgentSession { + id: string; + title: string; + projectId: string | null; + /** Enabled agent this chat belongs to, if any. Mutually exclusive with a project. */ + agentId?: string; + model: string; + createdAt: number; + updatedAt: number; + pinned: boolean; + archived: boolean; + messageCount: number; + isStreaming: boolean; + inputTokens: number; + outputTokens: number; + unread: boolean; + estimatedCost: number | null; + contextLength: number | null; + lastPromptTokens: number | null; + thresholdTokens: number | null; + /** Daemon lifecycle state (idle/running/awaiting/completed/failed/cancelled). */ + state?: string; + workspace?: string; + /** + * Action eligibility comes from the daemon row's capabilities, never + * re-derived client-side; an omitted capability is a denial, and the closed + * reason strings explain a disabled action. + */ + canRename?: boolean; + canDelete?: boolean; + renameReason?: string; + deleteReason?: string; + /** + * Title provenance off the inventory row (F4): "operator" (hand-set — an + * auto-rename must never clobber it), "first-prompt" (seeded, replaceable), + * "" (unknown/older daemon — do not auto-rename). + */ + titleProvenance?: string; + /** + * Non-empty on an AI-debug session (ADR 0254): the stored session this chat + * diagnoses. Drives the sidebar's "debug" badge. + */ + debugTargetSessionId?: string; +} + +export interface CreateSessionOpts { + workspace?: string; + model?: string; + projectId?: string; + /** Enabled agent this chat belongs to, if any. Mutually exclusive with a project. */ + agentId?: string; +} + +// ── Messages ──────────────────────────────────────────────────────────────── + +export interface AgentMessage { + id: string; + role: "user" | "assistant" | "tool"; + content: string; + timestamp: number; + attachments?: Attachment[]; + toolCalls?: ToolCallInfo[]; + reasoning?: string; + artifact?: Artifact; + /** + * When set on an assistant message, names the specific agent that authored + * that turn. This lets a single conversation surface several different agents + * (e.g. a project chat where a Code Reviewer, Security Auditor, and Docs + * Writer each contribute), rather than a single assistant identity. Falls back + * to the chat's default bot name when unset. + */ + agentName?: string; + /** + * A focused reply thread branched off this message. When present, the message + * shows a reply indicator; opening it reveals the root message and these + * replies in the side thread panel (mirrors the teams view's threading). + */ + replies?: AgentMessage[]; + /** One-line advisories from the daemon (tool progress, unrendered events). */ + notices?: string[]; + /** Delegation cards: work this turn handed to child agents. */ + delegations?: DelegationInfo[]; + /** + * The turn ended with result.stop === "error". A failed turn renders as + * failed — never as an empty success. + */ + failed?: boolean; + failureDetail?: string; +} + +/** + * One delegated child on an assistant turn. Starts as a badge (subagent.start + * & friends), then live-updates while the child works — `subagent.tool` + * frames tick `toolCount`/token counters — and settles on `subagent.end` + * with the stop reason, duration, and (for a failed child) the cause, so a + * failed child never silently vanishes. Keyed by `childId` where the daemon + * names one; team/parallel entries may not carry an id and stay static. + */ +export interface DelegationInfo { + kind: "subagent" | "team" | "parallel"; + label: string; + detail: string; + /** The child session id (`subagent.start` child_id) — the update key. */ + childId?: string; + /** Detached-delivery child: the parent run continues while it works. */ + background?: boolean; + /** Why the model router did NOT route this delegation (bare metadata). */ + routingReason?: string; + /** Cumulative child tool-call count (running, then final). */ + toolCount?: number; + /** Cumulative child token accounting (live per turn, final on end). */ + inputTokens?: number; + outputTokens?: number; + /** The child's most recent tool, from the bounded activity projection. */ + lastTool?: string; + /** Terminal stop reason; presence means the child has ended. */ + stop?: string; + /** Wall-clock child duration in milliseconds (end only, best-effort). */ + durationMs?: number; + /** Failure detail when stop === "error" (harness metadata, clamped). */ + cause?: string; +} + +export interface ToolCallInfo { + callId: string; + name: string; + input: unknown; + /** The file this call produced (Write), previewable in the canvas. */ + file?: import("@/lib/file-meta").ToolCallFile; + output?: string; + isError?: boolean; + status: "running" | "completed" | "failed"; +} + +export interface Attachment { + name: string; + type: string; + url?: string; + content?: string; +} + +// ── Stream Events ─────────────────────────────────────────────────────────── + +/** + * Every translated event may carry the opaque, server-minted id of the run + * that emitted it (Event.run_id, ADR 0249). Empty/absent is meaningful: the + * event is session-scoped (e.g. schedule lifecycle), not run-scoped. Clients + * compare it for equality only — it is the handle `expected_run_id` controls + * (approve/cancel) are scoped to. + */ +export type StreamEvent = StreamEventBody & { runId?: string }; + +type StreamEventBody = + | { type: "token"; text: string } + | { + type: "tool_call"; + name: string; + callId: string; + input: unknown; + file?: import("@/lib/file-meta").ToolCallFile; + } + | { + type: "tool_result"; + callId: string; + output: string; + isError?: boolean; + } + | { + type: "approval"; + approvalId: string; + sessionId: string; + toolName: string; + description: string; + details: string; + } + | { + type: "clarify"; + clarifyId: string; + sessionId: string; + question: string; + } + | { type: "reasoning"; text: string } + | { + type: "usage"; + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + estimatedCost: number | null; + } + | { type: "title"; title: string } + | { + type: "done"; + session: AgentSession; + messages: AgentMessage[]; + } + | { type: "error"; message: string; details?: string } + /** A previously surfaced permission ask was withdrawn by the daemon. */ + | { type: "retract"; approvalId: string } + /** + * Mid-run steer drain echo: the daemon merged the pending steer bundle into + * the in-flight run. `text` is the drained bundle; `messageId` is the + * watermark — the client-minted id of the LAST message the bundle absorbed. + * `parts` is the committed media bundle (ADR 0251), byte-identical to what + * history recorded, so a rebuilt transcript keeps the steer's attachments. + */ + | { type: "steer"; text: string; messageId: string; parts?: SteerEchoPart[] } + /** A one-line advisory (tool progress, compaction, unrendered event kinds). */ + | { type: "notice"; text: string } + /** + * A recorded user message from the durable-log replay (EvUserPrompt): the + * watch's record of what the user asked. Log-only — the live prompt stream + * never carries it (the client already holds its own optimistic bubble). + */ + | { type: "user_prompt"; text: string } + /** + * The resolved verdict half of a permission ask, from the durable-log + * replay (EvApproval). Metadata only: tool NAME + verdict string + * (allow_once / allow_always / deny) + the ask it resolved — never args. + */ + | { + type: "approval_verdict"; + approvalId: string; + toolName: string; + verdict: string; + } + /** Delegation activity: the run handed work to a child agent. */ + | { + type: "delegation"; + kind: "subagent" | "team" | "parallel"; + label: string; + detail: string; + /** Child session id (subagent.start), keying later live updates. */ + childId?: string; + background?: boolean; + /** Why the router did not route this delegation (D2.1). */ + routingReason?: string; + } + /** + * Live child activity (subagent.tool): cumulative tool/token counters for + * the delegation card keyed by `childId`. Redacted metadata only. + */ + | { + type: "delegation_progress"; + childId: string; + toolCount?: number; + inputTokens?: number; + outputTokens?: number; + toolName?: string; + } + /** + * Child terminal (subagent.end): final counters, the stop reason, the + * wall-clock duration, and — when stop === "error" — the failure cause, + * so a failed child renders as failed instead of vanishing. + */ + | { + type: "delegation_end"; + childId: string; + stop: string; + toolCount?: number; + inputTokens?: number; + outputTokens?: number; + durationMs?: number; + cause?: string; + } + /** + * The run's terminal frame. `stop === "error"` is a FAILED turn and must + * render as one, even when no token ever streamed. `retryDisposition` / + * `streamProgress` are the typed failed-terminal classification (ADR 0239), + * presence-aware: absent against a daemon that predates them. + */ + | { + type: "run_result"; + stop: string; + text: string; + errorText: string; + permanent: boolean; + retryDisposition?: RetryDisposition; + streamProgress?: StreamProgress; + }; + +/** Typed failed-terminal classification (ADR 0239). */ +export type RetryDisposition = "unknown" | "retryable" | "permanent"; +/** How far the failed step's stream got before it died (ADR 0239). */ +export type StreamProgress = "unknown" | "precommit" | "visible" | "complete"; + +/** One committed media part off the steer drain echo (ADR 0251). */ +export interface SteerEchoPart { + kind: "image" | "audio"; + mimeType: string; + /** Standard base64 (inline bytes); empty when url-sourced. */ + data?: string; + url?: string; +} + +// ── Projects ──────────────────────────────────────────────────────────────── + +export interface ProjectMemory { + id: string; + content: string; + source: string; +} + +export interface ProjectSuggestion { + id: string; + label: string; +} + +export interface AgentProject { + id: string; + name: string; + color: string; + createdAt: number; + summary?: string; + status?: string; + memories?: ProjectMemory[]; + suggestions?: ProjectSuggestion[]; + outputFiles?: Artifact[]; +} + +// ── Agents ────────────────────────────────────────────────────────────────── + +export interface AgentRoster { + id: string; + name: string; + description?: string; + enabled: boolean; +} + +// ── Approvals ─────────────────────────────────────────────────────────────── + +export type ApprovalChoice = "once" | "session" | "always" | "deny"; + +export interface ApprovalRequest { + approvalId: string; + sessionId: string; + /** The tool being authorized ("" when the daemon did not name one). */ + toolName?: string; + description: string; + details: string; +} + +// ── Clarifications ────────────────────────────────────────────────────────── + +export interface ClarificationRequest { + clarifyId: string; + sessionId: string; + question: string; +} + +// ── Models ────────────────────────────────────────────────────────────────── + +export interface ModelInfo { + id: string; + name: string; + provider: string; +} + +// ── Memory ────────────────────────────────────────────────────────────────── + +export interface MemoryEntry { + id: string; + title: string; + content: string; + section: string; + updatedAt: number; +} + +// ── Skills ────────────────────────────────────────────────────────────────── + +export interface Skill { + name: string; + description: string; + category: string; + content?: string; +} + +// ── Cron ──────────────────────────────────────────────────────────────────── + +export interface CronRunRecord { + id: string; + startedAt: string; + durationMs: number; + status: "success" | "error" | "retrying"; + message: string; +} + +export interface CronJob { + id: string; + name: string; + schedule: string; + instruction: string; + enabled: boolean; + status: "idle" | "running" | "error"; + lastRunAt: number | null; + output: string | null; + /** Live harness only: session id of the last completed fire. */ + lastRunSessionId?: string; + prompt?: string; + skills?: string[]; + tools?: string[]; + targetChannel?: string; + history?: CronRunRecord[]; +} + +export interface CreateCronOpts { + name: string; + schedule: string; + instruction: string; + deliver?: string; + skills?: string[]; + model?: string; +} + +// ── Files ─────────────────────────────────────────────────────────────────── + +export interface FileEntry { + name: string; + path: string; + type: "file" | "dir" | "symlink"; + size: number | null; +} + +export interface FileContent { + path: string; + content: string; + size: number; + lines: number; +} + +export interface GitInfo { + isGit: boolean; + branch: string | null; + dirty: number; + modified: number; + untracked: number; + ahead: number; + behind: number; +} + +// ── Artifacts (UI-level) ──────────────────────────────────────────────────── + +export interface Artifact { + name: string; + type: "spreadsheet" | "document" | "code" | "image" | "pdf" | "markdown"; + content?: string; + /** Binary payloads (images, PDFs) that don't fit `content` as text ride a + * URL — a data: URI or a fetchable location. */ + url?: string; + createdAt?: string; +} diff --git a/studio/src/lib/file-meta.test.ts b/studio/src/lib/file-meta.test.ts new file mode 100644 index 0000000000..4bf74bba59 --- /dev/null +++ b/studio/src/lib/file-meta.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { fileFromToolCall } from "./file-meta"; + +describe("fileFromToolCall", () => { + it("derives the file from a Write call's daemon-shaped args", () => { + expect( + fileFromToolCall( + "Write", + JSON.stringify({ path: "content/mock/tv.md", content: "# hi" }), + ), + ).toEqual({ path: "content/mock/tv.md", name: "tv.md", content: "# hi" }); + }); + it("accepts the file_path variant", () => { + expect( + fileFromToolCall( + "Write", + JSON.stringify({ file_path: "a/b.ts", content: "x" }), + )?.name, + ).toBe("b.ts"); + }); + it("ignores non-Write tools, malformed JSON, and pathless args", () => { + expect(fileFromToolCall("Edit", '{"path":"a.md"}')).toBeUndefined(); + expect(fileFromToolCall("Write", "not json")).toBeUndefined(); + expect(fileFromToolCall("Write", '{"content":"x"}')).toBeUndefined(); + }); +}); diff --git a/studio/src/lib/file-meta.ts b/studio/src/lib/file-meta.ts new file mode 100644 index 0000000000..23f677dfbb --- /dev/null +++ b/studio/src/lib/file-meta.ts @@ -0,0 +1,144 @@ +import { + FileCode2, + FileText, + Image as ImageIcon, + type LucideIcon, + Paperclip, +} from "lucide-react"; + +/** + * File-kind classification shared by every surface that shows a file chip + * (composer attachment pills, message attachment chips, file cards): one + * icon + label per kind, so the surfaces agree on what a file "is". + */ +interface FileKindMeta { + icon: LucideIcon; + label: string; +} + +/** + * Source-file extensions the app treats as code. Owned here so the + * file-preview code viewer and the file-kind icons share ONE list (the + * preview panel previously kept its own copy). + */ +const CODE_FILE_EXTENSIONS: ReadonlySet = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "json", + "py", + "sh", + "bash", + "zsh", + "go", + "rs", + "java", + "rb", + "php", + "c", + "cpp", + "h", + "hpp", + "cs", + "kt", + "swift", + "yml", + "yaml", + "toml", + "sql", + "css", + "scss", + "html", + "xml", + "graphql", + "prisma", +]); + +const IMAGE_EXTENSIONS: ReadonlySet = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "heic", + "heif", + "avif", + "bmp", +]); + +/** Lower-cased extension of a file name, "" when it has none. */ +function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot + 1).toLowerCase(); +} + +/** + * Icon + label for a file, from its MIME type when available and its + * extension otherwise. Images → image glyph, PDFs and Markdown → document + * glyph, recognised source files → code glyph, everything else keeps the + * paperclip fallback. + */ +function fileKindMeta(name: string, mime?: string): FileKindMeta { + const ext = extensionOf(name); + if (mime?.startsWith("image/") || IMAGE_EXTENSIONS.has(ext)) { + return { icon: ImageIcon, label: "Image" }; + } + if (mime === "application/pdf" || ext === "pdf") { + return { icon: FileText, label: "PDF" }; + } + if (ext === "md" || ext === "markdown") { + return { icon: FileText, label: "Markdown" }; + } + if (CODE_FILE_EXTENSIONS.has(ext)) { + return { icon: FileCode2, label: "Code" }; + } + return { icon: Paperclip, label: "File" }; +} + +/** A file a tool call produced, when the call's args carry one. */ +export interface ToolCallFile { + path: string; + name: string; + content?: string; +} + +/** + * Derives the produced file from a tool call's RAW args JSON. Write carries + * the full body (previewable); Edit names the path but not the final + * content, so it is deliberately skipped — a card that can't preview is a + * dead end. + */ +export function fileFromToolCall( + tool: string, + rawArgs: string | undefined, +): ToolCallFile | undefined { + if (tool !== "Write" || !rawArgs) return undefined; + try { + const args = JSON.parse(rawArgs) as { + // The daemon's Write takes "path"; "file_path" is accepted for + // robustness across tool-schema variants. + path?: unknown; + file_path?: unknown; + content?: unknown; + }; + const path = + typeof args.path === "string" && args.path + ? args.path + : typeof args.file_path === "string" && args.file_path + ? args.file_path + : undefined; + if (!path) return undefined; + const name = path.split("/").filter(Boolean).pop() ?? "file"; + return { + path, + name, + content: typeof args.content === "string" ? args.content : undefined, + }; + } catch { + return undefined; + } +} diff --git a/studio/src/lib/protocol/events.test.ts b/studio/src/lib/protocol/events.test.ts new file mode 100644 index 0000000000..9bdf999bb9 --- /dev/null +++ b/studio/src/lib/protocol/events.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it } from "vitest"; +import { parseMecatlEvent, parseWatchEnvelope, translateEvent } from "./events"; + +const translate = (payload: unknown) => + translateEvent(parseMecatlEvent(JSON.stringify(payload)), "session-1"); + +describe("parseMecatlEvent", () => { + it("throws on a frame with no type", () => { + expect(() => parseMecatlEvent(JSON.stringify({ text: "hi" }))).toThrow( + "event.type is required", + ); + }); + + it("preserves a terminal failure: stop, error text, and permanence", () => { + const event = parseMecatlEvent( + JSON.stringify({ + type: "result", + result: { + stop: "error", + error: "provider rejected the request", + permanent: true, + }, + }), + ); + expect(event.result?.stop).toBe("error"); + expect(event.result?.error).toBe("provider rejected the request"); + expect(event.result?.permanent).toBe(true); + }); +}); + +describe("parseWatchEnvelope", () => { + it("decodes an event-bearing envelope: inner event, cursor, phase", () => { + const envelope = parseWatchEnvelope( + JSON.stringify({ + event: { type: "message.delta", text: "hi", run_id: "run-1" }, + cursor: "c-42", + phase: "replay", + }), + ); + expect(envelope.cursor).toBe("c-42"); + expect(envelope.phase).toBe("replay"); + expect(envelope.event?.type).toBe("message.delta"); + expect(envelope.event?.run_id).toBe("run-1"); + }); + + it("decodes the event-less boundary frame with a null event", () => { + expect( + parseWatchEnvelope(JSON.stringify({ cursor: "c-9", phase: "live" })), + ).toEqual({ event: null, cursor: "c-9", phase: "live" }); + }); +}); + +describe("translateEvent", () => { + it("maps deltas to tokens and reasoning", () => { + expect(translate({ type: "message.delta", text: "abc" })).toEqual([ + { type: "token", text: "abc" }, + ]); + expect(translate({ type: "reasoning.delta", text: "hm" })).toEqual([ + { type: "reasoning", text: "hm" }, + ]); + }); + + it("renders a failed result as a run_result with the failure, never a success", () => { + const events = translate({ + type: "result", + result: { stop: "error", error: "boom", permanent: false }, + }); + expect(events).toEqual([ + { + type: "run_result", + stop: "error", + text: "", + errorText: "boom", + permanent: false, + }, + ]); + }); + + it("emits all five usage token fields from the terminal frame", () => { + const events = translate({ + type: "result", + result: { + stop: "end_turn", + text: "done", + usage: { + input_tokens: "100", + output_tokens: 50, + cache_read_tokens: 30, + cache_write_tokens: 10, + reasoning_tokens: 5, + }, + }, + }); + expect(events[0]).toEqual({ + type: "usage", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 30, + cacheWriteTokens: 10, + reasoningTokens: 5, + estimatedCost: null, + }); + expect(events[1]).toMatchObject({ type: "run_result", stop: "end_turn" }); + }); + + it("surfaces a permission ask and its retraction", () => { + expect( + translate({ + type: "permission.ask", + ask: { ask_id: "a1", tool: "bash", reason: "runs a command" }, + }), + ).toEqual([ + { + type: "approval", + approvalId: "a1", + sessionId: "session-1", + toolName: "bash", + description: "bash needs your approval.", + details: "runs a command", + }, + ]); + expect( + translate({ type: "permission.retract", ask: { ask_id: "a1" } }), + ).toEqual([{ type: "retract", approvalId: "a1" }]); + }); + + it("renders delegation badges for subagent, team roster, and parallel branch starts", () => { + expect( + translate({ + type: "subagent.start", + subagent: { + goal: "explore the repo", + routed_category: "explore", + routed_model: "small-1", + }, + }), + ).toEqual([ + { + type: "delegation", + kind: "subagent", + label: "explore the repo", + detail: "explore → small-1", + }, + ]); + expect( + translate({ + type: "team.start", + team: { + roster: [ + { name: "reviewer", role: "lead", model: "big-1" }, + { name: "tester" }, + ], + }, + }), + ).toEqual([ + { + type: "delegation", + kind: "team", + label: "reviewer (lead)", + detail: "big-1", + }, + { type: "delegation", kind: "team", label: "tester", detail: "" }, + ]); + expect( + translate({ + type: "parallel.branch", + parallel: { kind: "branch_start", branch_index: 1 }, + }), + ).toEqual([ + { type: "delegation", kind: "parallel", label: "branch 2", detail: "" }, + ]); + // Only a branch START is a badge; other branch lifecycle frames are silent. + expect( + translate({ type: "parallel.branch", parallel: { kind: "branch_end" } }), + ).toEqual([]); + }); + + it("translates a steer drain echo to the steer arm with its watermark id", () => { + expect( + translate({ + type: "steer", + steer: { text: "focus on the failing test", message_id: "steer-7-2" }, + }), + ).toEqual([ + { + type: "steer", + text: "focus on the failing test", + messageId: "steer-7-2", + }, + ]); + // A malformed echo still surfaces (empty fields), never a silent drop. + expect(translate({ type: "steer" })).toEqual([ + { type: "steer", text: "", messageId: "" }, + ]); + }); + + it("surfaces an unknown event kind as a notice, never a silent drop", () => { + expect(translate({ type: "something.new" })).toEqual([ + { + type: "notice", + text: "Mecatl sent an event this Studio version does not render yet: something.new", + }, + ]); + }); + + it("passes advisory text through as a notice and keeps lifecycle markers silent", () => { + expect( + translate({ type: "recover_notice", text: "resumed after a retry" }), + ).toEqual([{ type: "notice", text: "resumed after a retry" }]); + expect(translate({ type: "turn.start" })).toEqual([]); + expect(translate({ type: "session.init" })).toEqual([]); + // Routing is an implementation detail, not something shown per turn. + expect( + translate({ type: "provider.route", text: "routed to small-1" }), + ).toEqual([]); + }); + + it("renders a durable-log user_prompt as a user message event", () => { + expect( + translate({ type: "user_prompt", user_prompt: { text: "fix the bug" } }), + ).toEqual([{ type: "user_prompt", text: "fix the bug" }]); + // A media-only prompt (no text) stays quiet rather than an empty bubble. + expect(translate({ type: "user_prompt", user_prompt: {} })).toEqual([]); + }); + + it("renders a durable-log approval as a verdict event — tool name and verdict, never args", () => { + expect( + translate({ + type: "approval", + approval: { ask_id: "a1", tool: "Bash", verdict: "allow_once" }, + }), + ).toEqual([ + { + type: "approval_verdict", + approvalId: "a1", + toolName: "Bash", + verdict: "allow_once", + }, + ]); + }); + + it("keeps the compaction archive silent — audit history, not transcript", () => { + expect(translate({ type: "compaction.archive" })).toEqual([]); + }); + + it("stamps run_id onto every translated event (ADR 0249)", () => { + expect( + translate({ type: "message.delta", text: "x", run_id: "run-7" }), + ).toEqual([{ type: "token", text: "x", runId: "run-7" }]); + const results = translate({ + type: "result", + run_id: "run-7", + result: { stop: "end_turn", text: "done" }, + }); + expect(results.every((event) => event.runId === "run-7")).toBe(true); + // Empty is meaningful — a session-scoped event stays unstamped. + expect(translate({ type: "message.delta", text: "x" })).toEqual([ + { type: "token", text: "x" }, + ]); + }); + + it("decodes the typed retry disposition and stream progress off a failed terminal (ADR 0239)", () => { + // Numeric enums: stdlib encoding/json (mecated's HTTP surface). + const [failed] = translate({ + type: "result", + result: { + stop: "error", + error: "stream died", + retry_disposition: 2, + stream_progress: 2, + }, + }); + expect(failed).toMatchObject({ + type: "run_result", + stop: "error", + retryDisposition: "retryable", + streamProgress: "precommit", + }); + // 3 = permanent, 4 = complete. + const [permanent] = translate({ + type: "result", + result: { + stop: "error", + error: "bad request", + permanent: true, + retry_disposition: 3, + stream_progress: 4, + }, + }); + expect(permanent).toMatchObject({ + retryDisposition: "permanent", + streamProgress: "complete", + permanent: true, + }); + // SCREAMING_CASE names (a protojson relay) normalise the same way. + const [named] = translate({ + type: "result", + result: { + stop: "error", + retry_disposition: "RETRY_DISPOSITION_RETRYABLE", + stream_progress: "STREAM_PROGRESS_VISIBLE", + }, + }); + expect(named).toMatchObject({ + retryDisposition: "retryable", + streamProgress: "visible", + }); + }); + + it("keeps the retry fields ABSENT against an old daemon, and degrades unrecognized values to unknown", () => { + // Presence-aware: an old server sends neither field. + const [legacy] = translate({ + type: "result", + result: { stop: "error", error: "boom" }, + }); + expect(legacy).toMatchObject({ type: "run_result" }); + expect( + (legacy as { retryDisposition?: string }).retryDisposition, + ).toBeUndefined(); + expect( + (legacy as { streamProgress?: string }).streamProgress, + ).toBeUndefined(); + // A present-but-unrecognized value must never read as retryable. + const [odd] = translate({ + type: "result", + result: { stop: "error", retry_disposition: 99, stream_progress: 99 }, + }); + expect(odd).toMatchObject({ + retryDisposition: "unknown", + streamProgress: "unknown", + }); + }); + + it("renders model.retry as the quiet retrying line (B2.1)", () => { + expect( + translate({ + type: "model.retry", + model_retry: { retry_disposition: 2, stream_progress: 2 }, + }), + ).toEqual([{ type: "notice", text: "Retrying the failed step…" }]); + }); + + it("decodes the steer echo's committed media parts (ADR 0251)", () => { + const [echo] = translate({ + type: "steer", + steer: { + text: "look at this", + message_id: "steer-3", + parts: [ + { kind: 1, mime_type: "image/png", data: "aGk=" }, + { kind: "KIND_AUDIO", mime_type: "audio/wav", url: "mecatl://a" }, + { kind: 0, mime_type: "application/x-unknown", data: "ignored" }, + ], + }, + }); + expect(echo).toEqual({ + type: "steer", + text: "look at this", + messageId: "steer-3", + parts: [ + { kind: "image", mimeType: "image/png", data: "aGk=", url: undefined }, + { + kind: "audio", + mimeType: "audio/wav", + data: undefined, + url: "mecatl://a", + }, + ], + }); + // A part-less echo stays part-less (no empty array invented). + const [plain] = translate({ + type: "steer", + steer: { text: "just text", message_id: "steer-4" }, + }); + expect((plain as { parts?: unknown }).parts).toBeUndefined(); + }); + + it("translates subagent.tool into live delegation progress (D1)", () => { + expect( + translate({ + type: "subagent.tool", + subagent: { + parent_call_id: "call-1", + child_id: "subagent-abc", + tool_name: "Read", + tool_count: 4, + usage: { input_tokens: "1200", output_tokens: 300 }, + }, + }), + ).toEqual([ + { + type: "delegation_progress", + childId: "subagent-abc", + toolCount: 4, + inputTokens: 1200, + outputTokens: 300, + toolName: "Read", + }, + ]); + // A frame with no child id has nothing to key on and stays quiet. + expect(translate({ type: "subagent.tool", subagent: {} })).toEqual([]); + }); + + it("translates subagent.end into the terminal card update — a failed child carries its cause (D1)", () => { + expect( + translate({ + type: "subagent.end", + subagent: { + child_id: "subagent-abc", + stop: "error", + tool_count: 7, + duration_ms: 4200, + cause: "provider rejected the request", + usage: { input_tokens: 9000, output_tokens: 1500 }, + }, + }), + ).toEqual([ + { + type: "delegation_end", + childId: "subagent-abc", + stop: "error", + toolCount: 7, + inputTokens: 9000, + outputTokens: 1500, + durationMs: 4200, + cause: "provider rejected the request", + }, + ]); + }); + + it("carries child_id, background, and routing_reason on the subagent start badge (D1/D2.1)", () => { + const [start] = translate({ + type: "subagent.start", + subagent: { + child_id: "subagent-abc", + goal: "explore the repo", + background: true, + routing_reason: "pinned-model", + }, + }); + expect(start).toMatchObject({ + type: "delegation", + kind: "subagent", + label: "explore the repo", + childId: "subagent-abc", + background: true, + routingReason: "pinned-model", + }); + }); + + it("pretty-prints tool args on the call card", () => { + expect( + translate({ + type: "tool.call", + tool_call: { + call_id: "c1", + tool: "bash", + args: JSON.stringify({ command: "ls", timeout: 5 }), + }, + }), + ).toEqual([ + { + type: "tool_call", + callId: "c1", + name: "bash", + input: "command: ls · timeout: 5", + }, + ]); + }); +}); diff --git a/studio/src/lib/protocol/events.ts b/studio/src/lib/protocol/events.ts new file mode 100644 index 0000000000..77ffe779de --- /dev/null +++ b/studio/src/lib/protocol/events.ts @@ -0,0 +1,669 @@ +/** + * The daemon event seam: structural decode of one SSE `session.Event` frame + * (`parseMecatlEvent`) and its translation into the UI's StreamEvent union + * (`translateEvent`). This module is the ONLY place that reads raw daemon + * event JSON. + * + * There are deliberately no generated proto bindings yet (ADR: the typed + * runtime seam plus behavior tests is the stopgap); decoding is structural + * and throw-on-missing-type so a malformed frame fails loudly in tests. + */ + +import type { + RetryDisposition, + SteerEchoPart, + StreamEvent, + StreamProgress, +} from "@/features/agent/types"; +import { fileFromToolCall } from "@/lib/file-meta"; +import { + asRecord, + enumNumber, + optionalNumber, + optionalString, + stringFields, + type UnknownRecord, +} from "./internal"; + +type MecatlUsage = { + input_tokens?: string | number; + output_tokens?: string | number; + cache_read_tokens?: string | number; + cache_write_tokens?: string | number; + reasoning_tokens?: string | number; +}; + +/** A wire enum: a NUMBER under stdlib encoding/json (mecated's HTTP surface), + * a SCREAMING_CASE name under protojson (a relay). */ +type WireEnum = string | number; + +/** One raw Content part off the steer drain echo (proto Content). */ +type MecatlContentPart = { + kind?: WireEnum; + mime_type?: string; + /** Standard base64 under stdlib encoding/json ([]byte). */ + data?: string; + url?: string; +}; + +export type MecatlEvent = { + type: string; + seq?: string | number; + /** Opaque id of the run that emitted this event (ADR 0249); "" on + * session-scoped events (e.g. the schedule lifecycle). */ + run_id?: string; + text?: string; + tool_call?: { + id?: string; + name?: string; + args?: string; + tool?: string; + call_id?: string; + }; + tool_result?: { + call_id?: string; + content?: string; + result?: string; + is_error?: boolean; + tool?: string; + }; + ask?: { ask_id?: string; tool?: string; args?: string; reason?: string }; + /** Drain echo for mid-run steering: `text` is the drained bundle, + * `message_id` the client-minted id of the LAST message merged into it + * (the watermark the client splits its pending list on), and `parts` the + * committed media bundle (ADR 0251). */ + steer?: { text?: string; message_id?: string; parts?: MecatlContentPart[] }; + /** The typed failed-terminal being retried (EvModelRetry, ADR 0239). */ + model_retry?: { + retry_disposition?: WireEnum; + stream_progress?: WireEnum; + }; + result?: { + text?: string; + stop?: string; + error?: string; + permanent?: boolean; + usage?: MecatlUsage; + /** Presence-aware (proto3 optional): absent on an older daemon. */ + retry_disposition?: WireEnum; + stream_progress?: WireEnum; + }; + subagent?: { + parent_call_id?: string; + child_id?: string; + goal?: string; + routed_category?: string; + routed_model?: string; + model?: string; + routing_reason?: string; + tool_name?: string; + stop?: string; + cause?: string; + background?: boolean; + tool_count?: number; + duration_ms?: number; + usage?: MecatlUsage; + }; + team?: { + parent_call_id?: string; + roster?: Array<{ + name?: string; + role?: string; + routed_category?: string; + routed_model?: string; + model?: string; + }>; + }; + parallel?: { + parent_call_id?: string; + kind?: string; + branch_index?: number; + branch_label?: string; + goal?: string; + routed_category?: string; + routed_model?: string; + model?: string; + }; + /** Log-only (EvApproval): the verdict half of a permission ask. Metadata + * only by construction — tool NAME + verdict string, never args. */ + approval?: { ask_id?: string; verdict?: string; tool?: string }; + /** Log-only (EvUserPrompt): the recorded user message. */ + user_prompt?: { text?: string }; +}; + +export function parseMecatlEvent(data: string): MecatlEvent { + const raw = asRecord(JSON.parse(data)); + return parseMecatlEventValue(raw); +} + +/** Structural decode of an already-parsed event object (the watch envelope + * carries the event nested, so it arrives pre-parsed). */ +function parseMecatlEventValue(raw: UnknownRecord | undefined): MecatlEvent { + if (!raw || typeof raw.type !== "string" || !raw.type) + throw new Error("event.type is required"); + const event: MecatlEvent = { + type: raw.type, + seq: + typeof raw.seq === "string" || typeof raw.seq === "number" + ? raw.seq + : undefined, + run_id: optionalString(raw.run_id), + text: optionalString(raw.text), + }; + event.tool_call = stringFields(raw.tool_call, [ + "id", + "name", + "args", + "tool", + "call_id", + ]); + const toolResult = asRecord(raw.tool_result); + if (toolResult) + event.tool_result = { + ...stringFields(toolResult, ["call_id", "content", "result", "tool"]), + is_error: + typeof toolResult.is_error === "boolean" + ? toolResult.is_error + : undefined, + }; + event.ask = stringFields(raw.ask, ["ask_id", "tool", "args", "reason"]); + const steer = asRecord(raw.steer); + if (steer) + event.steer = { + ...stringFields(steer, ["text", "message_id"]), + parts: Array.isArray(steer.parts) + ? steer.parts.flatMap((part): MecatlContentPart[] => { + const record = asRecord(part); + if (!record) return []; + return [ + { + kind: wireEnum(record.kind), + mime_type: optionalString(record.mime_type), + data: optionalString(record.data), + url: optionalString(record.url), + }, + ]; + }) + : undefined, + }; + const modelRetry = asRecord(raw.model_retry); + if (modelRetry) + event.model_retry = { + retry_disposition: wireEnum(modelRetry.retry_disposition), + stream_progress: wireEnum(modelRetry.stream_progress), + }; + const result = asRecord(raw.result); + if (result) + event.result = { + ...stringFields(result, ["text", "stop", "error"]), + permanent: + typeof result.permanent === "boolean" ? result.permanent : undefined, + usage: asRecord(result.usage) as MecatlUsage | undefined, + retry_disposition: wireEnum(result.retry_disposition), + stream_progress: wireEnum(result.stream_progress), + }; + const subagent = asRecord(raw.subagent); + if (subagent) + event.subagent = { + ...stringFields(subagent, [ + "parent_call_id", + "child_id", + "goal", + "routed_category", + "routed_model", + "model", + "routing_reason", + "tool_name", + "stop", + "cause", + ]), + background: subagent.background === true ? true : undefined, + tool_count: numericField(subagent.tool_count), + duration_ms: numericField(subagent.duration_ms), + usage: asRecord(subagent.usage) as MecatlUsage | undefined, + }; + const team = asRecord(raw.team); + if (team) + event.team = { + parent_call_id: optionalString(team.parent_call_id), + roster: Array.isArray(team.roster) + ? team.roster.map( + (member) => + stringFields(member, [ + "name", + "role", + "routed_category", + "routed_model", + "model", + ]) ?? {}, + ) + : undefined, + }; + const parallel = asRecord(raw.parallel); + if (parallel) + event.parallel = { + ...stringFields(parallel, [ + "parent_call_id", + "kind", + "branch_label", + "goal", + "routed_category", + "routed_model", + "model", + ]), + branch_index: optionalNumber(parallel.branch_index), + }; + event.approval = stringFields(raw.approval, ["ask_id", "verdict", "tool"]); + event.user_prompt = stringFields(raw.user_prompt, ["text"]); + return event; +} + +/** + * One delivery envelope from the durable session watch + * (GET /v1/sessions/{id}/watch, ADR 0250): what happened, where the client + * now is (the opaque resume cursor), and which phase it arrived in. + */ +export interface MecatlWatchEnvelope { + /** Null on a phase-only frame: the single replay→live boundary marker and + * every gap frame. */ + event: MecatlEvent | null; + /** Opaque resume token positioned AFTER this envelope; hand it back + * verbatim to continue from exactly the next record. */ + cursor: string; + /** Open string: "replay" | "live" | "gap" — tolerate unknown values. */ + phase: string; +} + +export function parseWatchEnvelope(data: string): MecatlWatchEnvelope { + const raw = asRecord(JSON.parse(data)); + if (!raw) throw new Error("watch envelope must be an object"); + const inner = asRecord(raw.event); + return { + event: inner ? parseMecatlEventValue(inner) : null, + cursor: optionalString(raw.cursor) ?? "", + phase: optionalString(raw.phase) ?? "", + }; +} + +/** Renders a tool's JSON args as a compact `key: value · key: value` line. */ +function prettyArgs(raw?: string): string { + if (!raw) return ""; + try { + const parsed = JSON.parse(raw) as Record; + return Object.entries(parsed) + .map( + ([key, value]) => + `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, + ) + .join(" · "); + } catch { + return raw; + } +} + +const tokens = (value: string | number | undefined) => { + const parsed = typeof value === "string" ? Number(value) : (value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +/** Passes an enum through presence-aware: number or name kept, else absent. */ +const wireEnum = (value: unknown): WireEnum | undefined => + typeof value === "number" || typeof value === "string" ? value : undefined; + +/** An int field that may ride as a string through a protojson relay. */ +const numericField = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +// The ADR-0239 failed-terminal enums. The Go mapper always stamps both on new +// servers (UNKNOWN=1 is a real value, distinct from ABSENT = old server), so +// the decode is presence-aware and an unrecognized value degrades to +// "unknown" — never to "retryable". +const RETRY_DISPOSITION_NAMES = { + RETRY_DISPOSITION_UNKNOWN: 1, + RETRY_DISPOSITION_RETRYABLE: 2, + RETRY_DISPOSITION_PERMANENT: 3, +}; +const RETRY_DISPOSITION_LABELS: Record = { + 1: "unknown", + 2: "retryable", + 3: "permanent", +}; +const STREAM_PROGRESS_NAMES = { + STREAM_PROGRESS_UNKNOWN: 1, + STREAM_PROGRESS_PRECOMMIT: 2, + STREAM_PROGRESS_VISIBLE: 3, + STREAM_PROGRESS_COMPLETE: 4, +}; +const STREAM_PROGRESS_LABELS: Record = { + 1: "unknown", + 2: "precommit", + 3: "visible", + 4: "complete", +}; + +const retryDispositionLabel = ( + value: WireEnum | undefined, +): RetryDisposition | undefined => + value === undefined + ? undefined + : (RETRY_DISPOSITION_LABELS[enumNumber(value, RETRY_DISPOSITION_NAMES)] ?? + "unknown"); + +const streamProgressLabel = ( + value: WireEnum | undefined, +): StreamProgress | undefined => + value === undefined + ? undefined + : (STREAM_PROGRESS_LABELS[enumNumber(value, STREAM_PROGRESS_NAMES)] ?? + "unknown"); + +// Steer-echo media parts (proto Content). KIND_UNSPECIFIED and unknown kinds +// are dropped — a part Studio cannot classify cannot be rendered either. +const CONTENT_KIND_NAMES = { KIND_IMAGE: 1, KIND_AUDIO: 2 }; +const CONTENT_KIND_LABELS: Record = { + 1: "image", + 2: "audio", +}; + +function decodeSteerParts( + parts: MecatlContentPart[] | undefined, +): SteerEchoPart[] | undefined { + if (!parts?.length) return undefined; + const decoded: SteerEchoPart[] = []; + for (const part of parts) { + const kind = CONTENT_KIND_LABELS[enumNumber(part.kind, CONTENT_KIND_NAMES)]; + if (!kind) continue; + decoded.push({ + kind, + mimeType: part.mime_type ?? "", + data: part.data || undefined, + url: part.url || undefined, + }); + } + return decoded.length > 0 ? decoded : undefined; +} + +const routingDetail = (source: { + routed_category?: string; + routed_model?: string; + model?: string; +}) => + [source.routed_category, source.routed_model || source.model] + .filter(Boolean) + .join(" → "); + +/** + * Event kinds that deliberately have no visual surface in Studio: run + * lifecycle markers, redacted child-activity detail beyond the start badge, + * and kinds that only ever appear in durable-log replays. The other two + * log-only kinds (`user_prompt`, `approval`) decode above — the durable + * watch (ADR 0250) replays them and they must render, not vanish. + */ +const SILENT_EVENT_KINDS = new Set([ + "session.init", + "turn.start", + "turn.end", + "hook", + // The pre-compaction conversation archive: audit history for the durable + // log, deliberately not re-rendered into the live transcript. + "compaction.archive", + "team.member", + "team.tasks", + "team.findings", + "team.end", + "parallel.start", + "parallel.end", + "schedule.fired", + "schedule.skipped", + "schedule.failed", + // The provider/model a prompt was routed to is an implementation detail, + // not something the operator asked to see under every turn. + "provider.route", +]); + +/** Advisory kinds whose `text` is worth a one-line notice in the flow. */ +const ADVISORY_EVENT_KINDS = new Set([ + "tool.progress", + "compaction", + "no_progress", + "recover_notice", +]); + +/** + * Translates one decoded daemon frame into zero or more StreamEvents. + * + * Unknown event kinds become a visible notice, never a silent drop — a new + * daemon capability must show up as "not rendered yet", not vanish. + * + * Every translated event is stamped with the frame's `run_id` (when the + * daemon sent one), so consumers can capture the active run's identity from + * the first run-bearing event and scope controls to it (ADR 0249). + */ +export function translateEvent( + event: MecatlEvent, + sessionId: string, +): StreamEvent[] { + const translated = translateEventBody(event, sessionId); + if (event.run_id) { + for (const item of translated) item.runId = event.run_id; + } + return translated; +} + +function translateEventBody( + event: MecatlEvent, + sessionId: string, +): StreamEvent[] { + switch (event.type) { + case "message.delta": + return event.text ? [{ type: "token", text: event.text }] : []; + case "reasoning.delta": + return event.text ? [{ type: "reasoning", text: event.text }] : []; + case "tool.call": { + const call = event.tool_call; + if (!call) return []; + return [ + { + type: "tool_call", + callId: call.call_id ?? call.id ?? `call-${event.seq ?? ""}`, + name: call.tool ?? call.name ?? "Tool", + input: call.args ? prettyArgs(call.args) : "", + file: fileFromToolCall(call.tool ?? call.name ?? "", call.args), + }, + ]; + } + case "tool.result": { + const result = event.tool_result; + if (!result) return []; + return [ + { + type: "tool_result", + callId: result.call_id ?? "", + output: result.result ?? result.content ?? "", + isError: result.is_error, + }, + ]; + } + case "permission.ask": { + const ask = event.ask; + if (!ask) return []; + return [ + { + type: "approval", + approvalId: ask.ask_id ?? "", + sessionId, + toolName: ask.tool ?? "", + description: `${ask.tool ?? "A tool"} needs your approval.`, + details: [ask.reason, prettyArgs(ask.args)] + .filter(Boolean) + .join("\n\n"), + }, + ]; + } + case "permission.retract": + return event.ask?.ask_id + ? [{ type: "retract", approvalId: event.ask.ask_id }] + : []; + case "steer": + // The daemon drained the pending steer bundle into the run. The echo + // carries the merged text, the watermark id of the last message it + // absorbed — the hook splits its pending list on that id — and the + // committed media bundle (ADR 0251). + return [ + { + type: "steer", + text: event.steer?.text ?? "", + messageId: event.steer?.message_id ?? "", + parts: decodeSteerParts(event.steer?.parts), + }, + ]; + case "model.retry": + // The daemon is re-driving the failed step (ADR 0239): a quiet system + // line, mirroring the other advisory kinds. + return [{ type: "notice", text: "Retrying the failed step…" }]; + case "subagent.start": { + const subagent = event.subagent; + if (!subagent) return []; + return [ + { + type: "delegation", + kind: "subagent", + label: subagent.goal || "subagent", + detail: routingDetail(subagent), + childId: subagent.child_id, + background: subagent.background, + routingReason: subagent.routing_reason || undefined, + }, + ]; + } + case "subagent.tool": { + // Live child activity (D1): cumulative counters for the delegation + // card. Only redacted metadata crosses — never child content. + const subagent = event.subagent; + if (!subagent?.child_id) return []; + const usage = subagent.usage; + return [ + { + type: "delegation_progress", + childId: subagent.child_id, + toolCount: subagent.tool_count, + inputTokens: usage ? tokens(usage.input_tokens) : undefined, + outputTokens: usage ? tokens(usage.output_tokens) : undefined, + toolName: subagent.tool_name || undefined, + }, + ]; + } + case "subagent.end": { + // Child terminal (D1): the stop reason, duration, final counters, and + // the failure cause — a failed child must render, never vanish. + const subagent = event.subagent; + if (!subagent?.child_id) return []; + const usage = subagent.usage; + return [ + { + type: "delegation_end", + childId: subagent.child_id, + stop: subagent.stop ?? "", + toolCount: subagent.tool_count, + inputTokens: usage ? tokens(usage.input_tokens) : undefined, + outputTokens: usage ? tokens(usage.output_tokens) : undefined, + durationMs: subagent.duration_ms, + cause: subagent.cause || undefined, + }, + ]; + } + case "team.start": { + const roster = event.team?.roster ?? []; + return roster.map((member) => ({ + type: "delegation" as const, + kind: "team" as const, + label: [member.name, member.role && `(${member.role})`] + .filter(Boolean) + .join(" "), + detail: routingDetail(member), + })); + } + case "parallel.branch": { + const parallel = event.parallel; + if (!parallel || parallel.kind !== "branch_start") return []; + const index = parallel.branch_index; + return [ + { + type: "delegation", + kind: "parallel", + label: + parallel.branch_label || + (typeof index === "number" ? `branch ${index + 1}` : "branch"), + detail: routingDetail(parallel), + }, + ]; + } + case "user_prompt": + // The durable log's record of what the user asked (EvUserPrompt). + // Only seen on watch/replay streams — the live prompt path never + // carries it. Empty text (a media-only prompt) stays quiet. + return event.user_prompt?.text + ? [{ type: "user_prompt", text: event.user_prompt.text }] + : []; + case "approval": { + // The verdict half of a permission ask (EvApproval), from the durable + // log. Metadata only: the tool's NAME and the verdict string. + const approval = event.approval; + if (!approval) return []; + return [ + { + type: "approval_verdict", + approvalId: approval.ask_id ?? "", + toolName: approval.tool ?? "", + verdict: approval.verdict ?? "", + }, + ]; + } + case "result": { + const result = event.result; + const events: StreamEvent[] = []; + const usage = result?.usage; + if (usage) { + events.push({ + type: "usage", + inputTokens: tokens(usage.input_tokens), + outputTokens: tokens(usage.output_tokens), + cacheReadTokens: tokens(usage.cache_read_tokens), + cacheWriteTokens: tokens(usage.cache_write_tokens), + reasoningTokens: tokens(usage.reasoning_tokens), + estimatedCost: null, + }); + } + // A well-formed result with stop === "error" is a FAILED turn and must + // render as one — the terminal frame always reaches the hook, even when + // it carries no usage and no text. + events.push({ + type: "run_result", + stop: result?.stop ?? "", + text: result?.text ?? "", + errorText: result?.error ?? "", + permanent: result?.permanent === true, + retryDisposition: retryDispositionLabel(result?.retry_disposition), + streamProgress: streamProgressLabel(result?.stream_progress), + }); + return events; + } + default: + if (SILENT_EVENT_KINDS.has(event.type)) return []; + if (ADVISORY_EVENT_KINDS.has(event.type)) { + return event.text ? [{ type: "notice", text: event.text }] : []; + } + return [ + { + type: "notice", + text: `Mecatl sent an event this Studio version does not render yet: ${event.type}`, + }, + ]; + } +} diff --git a/studio/src/lib/protocol/internal.ts b/studio/src/lib/protocol/internal.ts new file mode 100644 index 0000000000..d2f0955d62 --- /dev/null +++ b/studio/src/lib/protocol/internal.ts @@ -0,0 +1,69 @@ +/** Shared structural-decode helpers for the wire seam. Not exported by index. */ + +export type UnknownRecord = Record; + +export const asRecord = (value: unknown): UnknownRecord | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UnknownRecord) + : undefined; + +export const optionalString = (value: unknown) => + typeof value === "string" ? value : undefined; + +export const optionalNumber = (value: unknown) => + typeof value === "number" ? value : undefined; + +export const numeric = (value: unknown) => { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : 0; + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const booleanFlag = (value: unknown) => value === true; + +export function stringFields(value: unknown, names: string[]) { + const source = asRecord(value); + if (!source) return undefined; + return Object.fromEntries( + names.map((name) => [name, optionalString(source[name])]), + ) as Record; +} + +// A Timestamp is {seconds,nanos} under stdlib encoding/json and an RFC 3339 +// string under protojson. The zero time has no wire form, so absent, zero, and +// unparseable all collapse to null — "no next fire" is a real state (a +// one-shot that fired, a cron past max_fires) and must not read as 1970. +export function protoMillis(value: unknown): number | null { + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed !== 0 ? parsed : null; + } + const timestamp = asRecord(value); + const seconds = Number(timestamp?.seconds ?? 0); + if (!Number.isFinite(seconds) || seconds === 0) return null; + const nanos = Number(timestamp?.nanos ?? 0); + return ( + seconds * 1000 + Math.floor((Number.isFinite(nanos) ? nanos : 0) / 1e6) + ); +} + +// A Duration is {seconds,nanos} under stdlib encoding/json and a "1.5s" string +// under protojson. Returned in seconds because that is the unit the proto field +// is documented in and the unit the request form has to send back. +export function protoSeconds(value: unknown): number { + if (typeof value === "string") return numeric(value.replace(/s$/, "")); + const duration = asRecord(value); + if (!duration) return 0; + return numeric(duration.seconds) + numeric(duration.nanos) / 1e9; +} + +// An enum arrives as a NUMBER under stdlib encoding/json (what mecated's HTTP +// surface uses) and as its SCREAMING_CASE name under protojson (what a relay in +// front of it may use). Both normalise to the number the UI switches on, so a +// proxied deployment does not render every posture as "unset". +export const enumNumber = (value: unknown, names: Record) => + typeof value === "string" ? (names[value] ?? 0) : numeric(value); diff --git a/studio/src/lib/protocol/schedules.test.ts b/studio/src/lib/protocol/schedules.test.ts new file mode 100644 index 0000000000..1164af943f --- /dev/null +++ b/studio/src/lib/protocol/schedules.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { + decodeScheduleFires, + decodeScheduleRows, + encodeScheduleSpec, + scheduleDraftFromRow, +} from "./schedules"; + +const stdlibRow = { + spec: { + name: "nightly-digest", + prompt: "summarise the day", + trigger: { cron: "0 9 * * *" }, + timezone: "Europe/London", + workspace: "/repo", + mode: 2, + mutating: false, + max_fires: 30, + limits: { max_turns: 8, max_tool_calls: 40, max_consecutive_failures: 3 }, + selector: { provider_id: "openrouter", model_id: "big-1" }, + misfire: 2, + singleton: true, + carry_context: true, + fire_timeout: { seconds: 900 }, + parts: [{ kind: 2, mime_type: "image/png", data: "aGk=" }], + owner: { subject: "sub-1", name: "James" }, + }, + state: { + enabled: true, + fire_count: 4, + next_fire_at: { seconds: 1700003600 }, + last_fire_at: { seconds: 1700000000, nanos: 500000000 }, + last_fire_session_id: "sched--nightly-digest-20260817-090000-abcdef", + }, +}; + +describe("decodeScheduleRows", () => { + it("decodes stdlib-JSON shapes: {seconds,nanos} timestamps, numeric enums", () => { + const [row] = decodeScheduleRows({ schedules: [stdlibRow] }); + expect(row).toMatchObject({ + name: "nightly-digest", + cron: "0 9 * * *", + timezone: "Europe/London", + mode: 2, + mutating: false, + maxFires: 30, + enabled: true, + fireCount: 4, + owner: "James", + lastFireSessionId: "sched--nightly-digest-20260817-090000-abcdef", + fireStage: "idle", + }); + expect(row.nextFireAt).toBe(1700003600000); + expect(row.lastFireAt).toBe(1700000000500); + expect(row.limits).toEqual({ + maxTurns: 8, + maxToolCalls: 40, + maxConsecutiveFailures: 3, + }); + }); + + it("also accepts protojson shapes: RFC 3339 timestamps and enum names", () => { + const [row] = decodeScheduleRows({ + schedules: [ + { + spec: { + name: "x", + mode: "PERMISSION_MODE_PLAN", + misfire: "MISFIRE_SKIP", + trigger: { one_shot: "2026-08-20T09:00:00Z" }, + fire_timeout: "900s", + }, + state: {}, + }, + ], + }); + expect(row.mode).toBe(2); + expect(row.carried.misfire).toBe(2); + expect(row.oneShotAt).toBe(Date.parse("2026-08-20T09:00:00Z")); + expect(row.carried.fireTimeoutSeconds).toBe(900); + }); + + it("derives the fire stage from the claim sentinel and the started timestamp", () => { + const claimed = decodeScheduleRows({ + schedules: [ + { spec: { name: "a" }, state: { last_fire_session_id: "pending" } }, + ], + })[0]; + expect(claimed.fireStage).toBe("claimed"); + // The pending sentinel is a claim, not a session id. + expect(claimed.lastFireSessionId).toBe(""); + + const running = decodeScheduleRows({ + schedules: [ + { + spec: { name: "a" }, + state: { last_fire_started_at: { seconds: 1700000000 } }, + }, + ], + })[0]; + expect(running.fireStage).toBe("running"); + }); + + it("carries the fields the form cannot edit for the PUT round trip", () => { + const [row] = decodeScheduleRows({ schedules: [stdlibRow] }); + expect(row.carried).toEqual({ + selectorProvider: "openrouter", + selectorModel: "big-1", + misfire: 2, + singleton: true, + carryContext: true, + fireTimeoutSeconds: 900, + parts: [{ kind: 2, mime_type: "image/png", data: "aGk=" }], + }); + }); +}); + +describe("encodeScheduleSpec", () => { + it("re-encodes a decoded row as protojson: carried fields survive, well-known types convert", () => { + const [row] = decodeScheduleRows({ schedules: [stdlibRow] }); + const spec = encodeScheduleSpec(scheduleDraftFromRow(row), row.carried); + // Requests are protojson: Duration is a "900s" string, never {seconds:900}. + expect(spec.fire_timeout).toBe("900s"); + expect(spec.selector).toEqual({ + provider_id: "openrouter", + model_id: "big-1", + }); + expect(spec.singleton).toBe(true); + expect(spec.misfire).toBe(2); + expect(spec.carry_context).toBe(true); + expect(spec.parts).toEqual(stdlibRow.spec.parts); + // Server-owned fields are never sent. + expect(spec).not.toHaveProperty("owner"); + expect(spec).not.toHaveProperty("created_at"); + }); + + it("sends trigger-conditional fields only on the trigger they belong to", () => { + const cron = encodeScheduleSpec({ + name: "c", + prompt: "p", + trigger: { kind: "cron", cron: "* * * * *", timezone: "UTC" }, + profile: "", + workspace: "", + mode: 2, + mutating: false, + maxFires: 10, + limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 }, + oneShotRetry: true, + oneShotMaxRetries: 3, + }); + expect(cron.trigger).toEqual({ cron: "* * * * *" }); + expect(cron.timezone).toBe("UTC"); + expect(cron.max_fires).toBe(10); + expect(cron).not.toHaveProperty("one_shot_retry"); + + const oneShot = encodeScheduleSpec({ + name: "o", + prompt: "p", + trigger: { kind: "one-shot", at: Date.parse("2026-08-20T09:00:00Z") }, + profile: "", + workspace: "", + mode: 2, + mutating: false, + maxFires: 10, + limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 }, + oneShotRetry: true, + oneShotMaxRetries: 3, + }); + // A one-shot Timestamp is RFC 3339 in a request. + expect(oneShot.trigger).toEqual({ one_shot: "2026-08-20T09:00:00.000Z" }); + expect(oneShot.one_shot_retry).toBe(true); + expect(oneShot.one_shot_max_retries).toBe(3); + expect(oneShot).not.toHaveProperty("max_fires"); + expect(oneShot).not.toHaveProperty("timezone"); + }); + + it("omits carried fields on a create, where there is nothing to preserve", () => { + const spec = encodeScheduleSpec({ + name: "n", + prompt: "p", + trigger: { kind: "cron", cron: "* * * * *", timezone: "" }, + profile: "", + workspace: "", + mode: 2, + mutating: false, + maxFires: 0, + limits: { maxTurns: 0, maxToolCalls: 0, maxConsecutiveFailures: 0 }, + oneShotRetry: false, + oneShotMaxRetries: 0, + }); + expect(spec).not.toHaveProperty("singleton"); + expect(spec).not.toHaveProperty("misfire"); + expect(spec).not.toHaveProperty("selector"); + }); +}); + +describe("decodeScheduleFires", () => { + it("orders newest first, keys in-flight off the absent stop, and drops idless records", () => { + const fires = decodeScheduleFires({ + fires: [ + { + id: "f-old", + session_id: "s-old", + fired_at: { seconds: 1700000000 }, + stop: "end_turn", + }, + { + id: "f-new", + session_id: "s-new", + fired_at: { seconds: 1700007200 }, + started_at: { seconds: 1700007201 }, + }, + { session_id: "corrupt-no-id" }, + ], + }); + expect(fires.map((fire) => fire.id)).toEqual(["f-new", "f-old"]); + expect(fires[0].inFlight).toBe(true); + expect(fires[1].inFlight).toBe(false); + }); +}); diff --git a/studio/src/lib/protocol/schedules.ts b/studio/src/lib/protocol/schedules.ts new file mode 100644 index 0000000000..1cfec8091e --- /dev/null +++ b/studio/src/lib/protocol/schedules.ts @@ -0,0 +1,329 @@ +/** + * Decoders and encoders for the daemon's schedule registry. + * + * The sharp edge this module encodes: request bodies are decoded with + * PROTOJSON; responses are encoded with stdlib `encoding/json`. The two + * disagree on every well-known type — a Timestamp reads back as + * `{seconds,nanos}` but must be sent as RFC 3339, a Duration reads back as + * `{seconds}` but must be sent as `"5s"` — so a response body can never be + * echoed back as a request body. + */ + +import { + asRecord, + enumNumber, + numeric, + optionalString, + protoMillis, + protoSeconds, + type UnknownRecord, +} from "./internal"; + +/** + * Per-fire budgets (proto Limits). Zero DISABLES a limit rather than meaning + * "unset", so these are plain numbers: the form, the wire, and the daemon all + * read 0 as "no cap". + */ +type ScheduleLimits = { + maxTurns: number; + maxToolCalls: number; + maxConsecutiveFailures: number; +}; + +/** + * The spec fields Studio's form does not expose, carried VERBATIM across an + * edit. + * + * `PUT /v1/schedules/{name}` REPLACES the whole spec: the daemon preserves + * only the firing state, the creation timestamp, and the captured owner. + * Every other field a request omits is therefore deleted from the schedule — + * so a field this UI has no control for still has to make the round trip, or + * editing a prompt would silently drop a provider selector an operator set + * from the CLI. + * + * `parts` stays opaque on purpose. It is multimodal Content this client never + * renders, and decoding it into a typed shape only to re-encode it would be a + * second mapping of a message we need only hand back unchanged. + */ +export type ScheduleCarriedSpec = { + selectorProvider: string; + selectorModel: string; + misfire: number; + singleton: boolean; + carryContext: boolean; + /** A proto Duration in seconds, fractions allowed. 0 = the deployment default. */ + fireTimeoutSeconds: number; + parts: unknown[]; +}; + +export type ScheduleRow = { + name: string; + prompt: string; + cron: string; + oneShotAt: number | null; + timezone: string; + workspace: string; + profile: string; + mode: number; + mutating: boolean; + maxFires: number; + limits: ScheduleLimits; + oneShotRetry: boolean; + oneShotMaxRetries: number; + enabled: boolean; + fireCount: number; + nextFireAt: number | null; + lastFireAt: number | null; + fireStage: "idle" | "claimed" | "running"; + /** Prior fire's session id — "" while a fire is only claimed ("pending"). */ + lastFireSessionId: string; + /** + * Display label for the verified caller the schedule is attributed to, empty + * when the daemon runs without caller enforcement. Read-only on the wire: a + * create or update request naming an owner is ignored, so it is never part of + * an edit draft. + */ + owner: string; + carried: ScheduleCarriedSpec; +}; + +/** + * One fire record (`GET /v1/schedules/{name}/fires`, `…/fires/{id}`). + * + * A fire written by RecordFireStart is IN-FLIGHT — it has a `startedAt` and no + * `stop`; a fire written by RecordFire is terminal. A record with neither is a + * claim that never started its run (the crash-after-claim state), which is why + * `inFlight` keys off the ABSENT stop rather than off `startedAt`. + */ +export type ScheduleFireRow = { + id: string; + scheduleName: string; + sessionId: string; + firedAt: number | null; + startedAt: number | null; + progressAt: number | null; + deadline: number | null; + stop: string; + err: string; + inFlight: boolean; +}; + +/** + * The editable half of a spec: what the panel's form owns. + * + * The trigger is a SUM type here because it is one on the wire (cron XOR + * one-shot, a cross-field rule the daemon enforces fail-closed). Modelling it + * as two optional fields would let the form build a body the daemon must + * reject. + */ +type ScheduleTriggerDraft = + | { kind: "cron"; cron: string; timezone: string } + | { kind: "one-shot"; at: number }; + +export type ScheduleSpecDraft = { + name: string; + prompt: string; + trigger: ScheduleTriggerDraft; + profile: "" | "no-fs"; + workspace: string; + mode: number; + mutating: boolean; + maxFires: number; + limits: ScheduleLimits; + oneShotRetry: boolean; + oneShotMaxRetries: number; +}; + +const PERMISSION_MODES: Record = { + PERMISSION_MODE_UNSPECIFIED: 0, + PERMISSION_MODE_DEFAULT: 1, + PERMISSION_MODE_PLAN: 2, + PERMISSION_MODE_ACCEPT_EDITS: 3, +}; + +const MISFIRE_POLICIES: Record = { + MISFIRE_POLICY_UNSPECIFIED: 0, + MISFIRE_FIRE_ONCE_NOW: 1, + MISFIRE_SKIP: 2, +}; + +function decodeLimits(value: unknown): ScheduleLimits { + const limits = asRecord(value) ?? {}; + return { + maxTurns: numeric(limits.max_turns), + maxToolCalls: numeric(limits.max_tool_calls), + maxConsecutiveFailures: numeric(limits.max_consecutive_failures), + }; +} + +// The owner's `name` is display-only by contract and `subject` is the +// identity, so the label prefers the name and falls back to the id rather than +// inventing a friendly string. An absent owner stays empty: an ownerless +// schedule is never rendered as an anonymous somebody. +function decodeOwner(value: unknown): string { + const owner = asRecord(value); + if (!owner) return ""; + return optionalString(owner.name) || optionalString(owner.subject) || ""; +} + +export function decodeScheduleRows(value: unknown): ScheduleRow[] { + const body = asRecord(value); + if (!Array.isArray(body?.schedules)) return []; + return body.schedules.map((entryValue) => { + const entry = asRecord(entryValue) ?? {}; + const spec = asRecord(entry.spec) ?? {}; + const state = asRecord(entry.state) ?? {}; + const trigger = asRecord(spec.trigger) ?? {}; + const selector = asRecord(spec.selector) ?? {}; + const lastFireSessionId = String(state.last_fire_session_id ?? ""); + return { + name: String(spec.name ?? ""), + prompt: String(spec.prompt ?? ""), + cron: String(trigger.cron ?? ""), + oneShotAt: protoMillis(trigger.one_shot), + timezone: String(spec.timezone ?? ""), + workspace: String(spec.workspace ?? ""), + profile: String(spec.profile ?? ""), + mode: enumNumber(spec.mode, PERMISSION_MODES), + mutating: Boolean(spec.mutating), + maxFires: numeric(spec.max_fires), + limits: decodeLimits(spec.limits), + oneShotRetry: Boolean(spec.one_shot_retry), + oneShotMaxRetries: numeric(spec.one_shot_max_retries), + enabled: Boolean(state.enabled), + fireCount: numeric(state.fire_count), + nextFireAt: protoMillis(state.next_fire_at), + lastFireAt: protoMillis(state.last_fire_at), + fireStage: + protoMillis(state.last_fire_started_at) !== null + ? "running" + : lastFireSessionId === "pending" + ? "claimed" + : "idle", + lastFireSessionId: + lastFireSessionId === "pending" ? "" : lastFireSessionId, + owner: decodeOwner(spec.owner), + carried: { + selectorProvider: String(selector.provider_id ?? ""), + selectorModel: String(selector.model_id ?? ""), + misfire: enumNumber(spec.misfire, MISFIRE_POLICIES), + singleton: Boolean(spec.singleton), + carryContext: Boolean(spec.carry_context), + fireTimeoutSeconds: protoSeconds(spec.fire_timeout), + parts: Array.isArray(spec.parts) ? spec.parts : [], + }, + }; + }); +} + +function decodeFire(value: unknown): ScheduleFireRow | undefined { + const fire = asRecord(value); + const id = optionalString(fire?.id) ?? ""; + // A record with no id cannot be refreshed or correlated to a session; it is a + // corrupt envelope rather than a fire, so it is dropped instead of rendered. + if (!fire || !id) return undefined; + const stop = optionalString(fire.stop) ?? ""; + return { + id, + scheduleName: optionalString(fire.schedule_name) ?? "", + sessionId: optionalString(fire.session_id) ?? "", + firedAt: protoMillis(fire.fired_at), + startedAt: protoMillis(fire.started_at), + progressAt: protoMillis(fire.progress_at), + deadline: protoMillis(fire.deadline), + stop, + err: optionalString(fire.err) ?? "", + inFlight: stop === "", + }; +} + +/** + * `GET /v1/schedules/{name}/fires`, newest first. + * + * The API documents the list as having NO guaranteed order, so the display + * order is this decoder's job — an oversight surface that shows a random fire + * first is worse than useless. A record with no `fired_at` sorts last rather + * than first, which is where an un-clocked claim belongs. + */ +export function decodeScheduleFires(value: unknown): ScheduleFireRow[] { + const body = asRecord(value); + const rows = Array.isArray(body?.fires) ? body.fires : []; + return rows + .map(decodeFire) + .filter((fire): fire is ScheduleFireRow => fire !== undefined) + .sort((left, right) => (right.firedAt ?? 0) - (left.firedAt ?? 0)); +} + +/** The edit prefill: the stored row split into the half the form owns. */ +export function scheduleDraftFromRow(row: ScheduleRow): ScheduleSpecDraft { + return { + name: row.name, + prompt: row.prompt, + trigger: + !row.cron && row.oneShotAt !== null + ? { kind: "one-shot", at: row.oneShotAt } + : { kind: "cron", cron: row.cron, timezone: row.timezone }, + profile: row.profile === "no-fs" ? "no-fs" : "", + workspace: row.workspace, + mode: row.mode, + mutating: row.mutating, + maxFires: row.maxFires, + limits: row.limits, + oneShotRetry: row.oneShotRetry, + oneShotMaxRetries: row.oneShotMaxRetries, + }; +} + +/** + * The body for `POST /v1/schedules` and `PUT /v1/schedules/{name}`. + * + * `carried` is omitted on a create (there is nothing to preserve yet) and + * passed on an edit, where leaving it out would delete the fields this form + * cannot edit. Server-owned fields — `created_at` and `owner` — are + * deliberately never sent. + */ +export function encodeScheduleSpec( + draft: ScheduleSpecDraft, + carried?: ScheduleCarriedSpec, +): UnknownRecord { + const spec: UnknownRecord = { + name: draft.name, + prompt: draft.prompt, + profile: draft.profile, + workspace: draft.workspace, + mode: draft.mode, + mutating: draft.mutating, + limits: { + max_turns: draft.limits.maxTurns, + max_tool_calls: draft.limits.maxToolCalls, + max_consecutive_failures: draft.limits.maxConsecutiveFailures, + }, + }; + if (draft.trigger.kind === "cron") { + // max_fires bounds a cron's total fires; a one-shot fires once by + // definition and the daemon ignores it there. one_shot_retry is the mirror + // image — the create-seam REJECTS a cron that carries it, so neither field + // is sent on the trigger it does not belong to. + spec.trigger = { cron: draft.trigger.cron }; + spec.timezone = draft.trigger.timezone; + spec.max_fires = draft.maxFires; + } else { + spec.trigger = { one_shot: new Date(draft.trigger.at).toISOString() }; + spec.one_shot_retry = draft.oneShotRetry; + if (draft.oneShotRetry) spec.one_shot_max_retries = draft.oneShotMaxRetries; + } + if (!carried) return spec; + spec.singleton = carried.singleton; + spec.misfire = carried.misfire; + spec.carry_context = carried.carryContext; + if (carried.selectorProvider || carried.selectorModel) { + spec.selector = { + provider_id: carried.selectorProvider, + model_id: carried.selectorModel, + }; + } + if (carried.fireTimeoutSeconds > 0) + spec.fire_timeout = `${carried.fireTimeoutSeconds}s`; + if (carried.parts.length) spec.parts = carried.parts; + return spec; +} diff --git a/studio/src/lib/protocol/sessions.test.ts b/studio/src/lib/protocol/sessions.test.ts new file mode 100644 index 0000000000..908ee360b6 --- /dev/null +++ b/studio/src/lib/protocol/sessions.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import { + decodeSessionInventory, + decodeSessionPermissionMode, + decodeSessionTranscript, + encodeSessionPermissionMode, +} from "./sessions"; + +describe("decodeSessionInventory", () => { + it("takes action eligibility from the row's capabilities, never re-deriving it", () => { + const page = decodeSessionInventory({ + sessions: [ + { + session_id: "s1", + title: "Fix the flaky test", + state: "idle", + capabilities: { + rename: true, + delete: true, + view_transcript: true, + reasons: {}, + }, + }, + ], + }); + expect(page.sessions[0]).toMatchObject({ + sessionId: "s1", + canRename: true, + canDelete: true, + canViewTranscript: true, + isChat: true, + }); + }); + + it("treats an omitted capability as a denial", () => { + const page = decodeSessionInventory({ + sessions: [{ session_id: "s1", capabilities: {} }], + }); + expect(page.sessions[0]).toMatchObject({ + canRename: false, + canDelete: false, + canViewTranscript: false, + }); + }); + + it("filters chats on inspect_only_kind and ONLY that reason", () => { + const page = decodeSessionInventory({ + sessions: [ + { + session_id: "subagent-1", + capabilities: { reasons: { public_chat: "inspect_only_kind" } }, + }, + { + session_id: "s2", + capabilities: { reasons: { public_chat: "busy_running" } }, + }, + ], + }); + expect(page.sessions[0].isChat).toBe(false); + expect(page.sessions[1].isChat).toBe(true); + }); + + it("drops a row with no session id instead of rendering an inert chat", () => { + const page = decodeSessionInventory({ + sessions: [{ title: "corrupt" }, { session_id: "s1" }], + }); + expect(page.sessions).toHaveLength(1); + expect(page.sessions[0].sessionId).toBe("s1"); + }); + + it("carries the cursor and converts unix seconds, tolerating string int64", () => { + const page = decodeSessionInventory({ + sessions: [ + { session_id: "s1", modified_at_unix: 1700000000 }, + { session_id: "s2", modified_at_unix: "1700000001" }, + ], + next_cursor: "abc", + }); + expect(page.nextCursor).toBe("abc"); + expect(page.sessions[0].modifiedAt).toBe(1700000000000); + expect(page.sessions[1].modifiedAt).toBe(1700000001000); + }); + + it("carries per-action denial reasons for the UI to explain with", () => { + const page = decodeSessionInventory({ + sessions: [ + { + session_id: "s1", + capabilities: { + reasons: { rename: "busy_running", delete: "no_pruning" }, + }, + }, + ], + }); + expect(page.sessions[0].renameReason).toBe("busy_running"); + expect(page.sessions[0].deleteReason).toBe("no_pruning"); + }); + + it("decodes title_provenance verbatim, defaulting an absent field to unknown (F4)", () => { + const page = decodeSessionInventory({ + sessions: [ + { session_id: "s1", title: "Hand-set", title_provenance: "operator" }, + { session_id: "s2", title: "Seeded", title_provenance: "first-prompt" }, + { session_id: "s3", title: "Legacy row" }, + ], + }); + expect(page.sessions.map((s) => s.titleProvenance)).toEqual([ + "operator", + "first-prompt", + "", + ]); + }); + + it("decodes the debug relationship and treats a debug row as a chat despite inspect_only_kind (ADR 0254)", () => { + const page = decodeSessionInventory({ + sessions: [ + { + // The live daemon stamps debug rows inspect_only_kind (the KIND is + // not main) yet drives them as ordinary chats; the relationship is + // the honest chat signal. Rename/delete stay denied per the row. + session_id: "dbg-1", + kind: "debug", + relationship: { debug_target_session_id: "target-9" }, + capabilities: { + view_transcript: true, + reasons: { + public_chat: "inspect_only_kind", + rename: "inspect_only_kind", + delete: "inspect_only_kind", + }, + }, + }, + { + // A relationship WITHOUT the debug binding (a scheduled fire) stays + // inspect-only — the exception is the debug field, not any + // relationship. + session_id: "sched-1", + kind: "scheduled", + relationship: { schedule_name: "nightly" }, + capabilities: { + reasons: { public_chat: "inspect_only_kind" }, + }, + }, + ], + }); + const debug = page.sessions[0]; + expect(debug.debugTargetSessionId).toBe("target-9"); + expect(debug.isChat).toBe(true); + expect(debug.canRename).toBe(false); + expect(debug.canDelete).toBe(false); + const scheduled = page.sessions[1]; + expect(scheduled.debugTargetSessionId).toBe(""); + expect(scheduled.isChat).toBe(false); + }); +}); + +describe("decodeSessionTranscript", () => { + it("decodes messages with tool calls and results, and the completeness attestation", () => { + const transcript = decodeSessionTranscript({ + session_id: "s1", + complete: true, + messages: [ + { role: "user", text: "hello" }, + { + role: "assistant", + text: "", + tool_calls: [{ id: "c1", name: "bash", args: "{}" }], + }, + { + role: "tool", + tool_result: { call_id: "c1", content: "ok", is_error: false }, + }, + { role: "assistant", text: "done" }, + ], + }); + expect(transcript.sessionId).toBe("s1"); + expect(transcript.complete).toBe(true); + expect(transcript.messages).toHaveLength(4); + expect(transcript.messages[1].toolCalls).toEqual([ + { id: "c1", name: "bash", args: "{}" }, + ]); + expect(transcript.messages[2].toolResult).toEqual({ + callId: "c1", + content: "ok", + isError: false, + }); + }); + + it("reports an unproven transcript as incomplete rather than whole", () => { + const transcript = decodeSessionTranscript({ + session_id: "s1", + messages: [], + }); + expect(transcript.complete).toBe(false); + }); +}); + +describe("session permission mode mapping", () => { + it("decodes the daemon's echo spellings, mirroring the Go modeFromString", () => { + // The snapshot echoes session.PermissionMode verbatim: "acceptEdits". + expect(decodeSessionPermissionMode("acceptEdits")).toBe("acceptEdits"); + expect(decodeSessionPermissionMode("plan")).toBe("plan"); + expect(decodeSessionPermissionMode("default")).toBe("default"); + // The daemon's own parser tolerates these; the decoder matches it. + expect(decodeSessionPermissionMode("accept_edits")).toBe("acceptEdits"); + expect(decodeSessionPermissionMode("accept-edits")).toBe("acceptEdits"); + expect(decodeSessionPermissionMode("accept")).toBe("acceptEdits"); + expect(decodeSessionPermissionMode("PLAN")).toBe("plan"); + }); + + it("falls through to default for unknown, empty, or non-string values", () => { + expect(decodeSessionPermissionMode("")).toBe("default"); + expect(decodeSessionPermissionMode("yolo")).toBe("default"); + expect(decodeSessionPermissionMode(undefined)).toBe("default"); + expect(decodeSessionPermissionMode(3)).toBe("default"); + }); + + it("encodes requests as protojson snake_case, never the camelCase echo", () => { + expect(encodeSessionPermissionMode("default")).toBe("default"); + expect(encodeSessionPermissionMode("plan")).toBe("plan"); + expect(encodeSessionPermissionMode("acceptEdits")).toBe("accept_edits"); + }); + + it("round-trips every mode through encode → decode", () => { + for (const mode of ["default", "plan", "acceptEdits"] as const) { + expect( + decodeSessionPermissionMode(encodeSessionPermissionMode(mode)), + ).toBe(mode); + } + }); +}); diff --git a/studio/src/lib/protocol/sessions.ts b/studio/src/lib/protocol/sessions.ts new file mode 100644 index 0000000000..70495fa1cd --- /dev/null +++ b/studio/src/lib/protocol/sessions.ts @@ -0,0 +1,221 @@ +/** + * Decoders for the daemon's stored-session inventory and transcripts. + * + * The daemon — not this client — decides which actions a row supports: a + * subagent or team member is inspect-only, a running or awaiting chat cannot + * be renamed or deleted, and a store without pruning cannot delete at all. + * Each capability therefore travels with a closed machine-readable reason, so + * the UI explains a disabled action instead of re-deriving server eligibility + * rules and drifting from them. + */ + +import { + asRecord, + booleanFlag, + optionalNumber, + optionalString, +} from "./internal"; + +type SessionSummary = { + sessionId: string; + /** The server-held label: operator-authored, or seeded from the first prompt. */ + title: string; + /** + * Where `title` came from: "operator" (hand-set — an auto-rename must never + * clobber it), "first-prompt" (seeded, safe to replace), or "" (legacy row / + * older daemon — treat as unknown and do not auto-rename). + */ + titleProvenance: string; + /** + * Non-empty on an AI-debug session (ADR 0254): the id of the stored session + * this row was created to diagnose. The one relationship field a chat row + * can carry — every other related kind is inspect-only. + */ + debugTargetSessionId: string; + /** Persisted lifecycle state (idle/running/awaiting/completed/failed/cancelled). */ + state: string; + workspace: string; + modelId: string; + turns: number; + /** Last write, epoch millis. Zero when the row carried no timestamp. */ + modifiedAt: number; + /** Creation time, epoch millis. Zero when the snapshot could not be read. */ + createdAt: number; + /** + * Whether the row is an operator-facing chat at all. `inspect_only_kind` is + * the ONE reason that means "not a chat" (a subagent, team member, parallel + * branch, or scheduled fire) — EXCEPT for a debug session, whose kind is + * stamped inspect-only but which the daemon drives as an ordinary chat (see + * the decode). Every other reason means "a chat, busy right now", which + * still belongs in the list. + */ + isChat: boolean; + canRename: boolean; + canDelete: boolean; + canViewTranscript: boolean; + renameReason: string; + deleteReason: string; +}; + +export type SessionInventoryPage = { + sessions: SessionSummary[]; + nextCursor: string; +}; + +/** + * The closed session permission-mode vocabulary, spelled the way the daemon's + * session aggregate spells it (session.PermissionMode: "default" / "plan" / + * "acceptEdits"). This is what the session snapshot's `mode` field echoes. + */ +export type SessionPermissionMode = "default" | "plan" | "acceptEdits"; + +/** + * Decodes a session snapshot's `mode` echo into the closed vocabulary. + * Tolerant the same way the daemon's own modeFromString is (case-insensitive, + * "accept"/"accept-edits"/"accept_edits"/"acceptEdits" all mean accept-edits); + * unknown or empty values fall through to "default" — the daemon applies the + * default mode to a session created without one. + */ +export function decodeSessionPermissionMode( + value: unknown, +): SessionPermissionMode { + if (typeof value !== "string") return "default"; + switch (value.toLowerCase()) { + case "plan": + return "plan"; + case "accept": + case "acceptedits": + case "accept-edits": + case "accept_edits": + return "acceptEdits"; + default: + return "default"; + } +} + +/** + * The wire spelling for requests that carry a mode (session creation and + * POST /v1/sessions/{id}/mode) — protojson snake_case, per the + * requests-are-protojson rule. Never echo the decoded camelCase back. + */ +export function encodeSessionPermissionMode( + mode: SessionPermissionMode, +): "default" | "plan" | "accept_edits" { + return mode === "acceptEdits" ? "accept_edits" : mode; +} + +// int64 fields cross encoding/json as numbers. A value that arrives as a string +// (a protojson-shaped proxy, a hand-written stub) must still not become NaN. +const unixSecondsToMillis = (value: unknown) => { + const seconds = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : 0; + return Number.isFinite(seconds) ? Math.trunc(seconds) * 1000 : 0; +}; + +export function decodeSessionInventory(value: unknown): SessionInventoryPage { + const body = asRecord(value); + const rows = Array.isArray(body?.sessions) ? body.sessions : []; + const sessions: SessionSummary[] = []; + for (const rowValue of rows) { + const row = asRecord(rowValue); + const sessionId = optionalString(row?.session_id) ?? ""; + // A row with no id cannot be opened, renamed, or deleted. That is a corrupt + // envelope rather than a session, so it is dropped instead of rendered as an + // inert chat the operator can never act on. + if (!sessionId) continue; + const capabilities = asRecord(row?.capabilities) ?? {}; + const reasons = asRecord(capabilities.reasons) ?? {}; + const relationship = asRecord(row?.relationship) ?? {}; + const debugTargetSessionId = + optionalString(relationship.debug_target_session_id) ?? ""; + sessions.push({ + sessionId, + title: optionalString(row?.title) ?? "", + titleProvenance: optionalString(row?.title_provenance) ?? "", + debugTargetSessionId, + state: optionalString(row?.state) ?? "", + workspace: optionalString(row?.workspace) ?? "", + modelId: optionalString(row?.model_id) ?? "", + turns: optionalNumber(row?.turns) ?? 0, + modifiedAt: unixSecondsToMillis(row?.modified_at_unix), + createdAt: unixSecondsToMillis(row?.created_at_unix), + // A debug session (ADR 0254) is the one non-main kind that IS a chat: + // the daemon's run entry drives it like any main session, but its + // inventory row is stamped `inspect_only_kind` because the KIND is not + // main. The relationship is the honest chat signal there; its per-action + // capabilities (rename/delete denied) still bind below. + isChat: + (optionalString(reasons.public_chat) ?? "") !== "inspect_only_kind" || + debugTargetSessionId !== "", + canRename: booleanFlag(capabilities.rename), + canDelete: booleanFlag(capabilities.delete), + canViewTranscript: booleanFlag(capabilities.view_transcript), + renameReason: optionalString(reasons.rename) ?? "", + deleteReason: optionalString(reasons.delete) ?? "", + }); + } + return { sessions, nextCursor: optionalString(body?.next_cursor) ?? "" }; +} + +/** One conversation entry from `GET /v1/sessions/{id}/transcript`. */ +type TranscriptMessage = { + role: string; + text: string; + toolCalls: Array<{ id: string; name: string; args: string }>; + toolResult?: { callId: string; content: string; isError: boolean }; +}; + +export type SessionTranscript = { + sessionId: string; + /** + * The daemon's completeness attestation. A successful load is complete even + * for a genuinely empty conversation, so `false` means the transcript could + * NOT be proven whole — the UI says so rather than presenting a partial + * history as the whole one. + */ + complete: boolean; + messages: TranscriptMessage[]; +}; + +export function decodeSessionTranscript(value: unknown): SessionTranscript { + const body = asRecord(value); + const rows = Array.isArray(body?.messages) ? body.messages : []; + const messages: TranscriptMessage[] = []; + for (const messageValue of rows) { + const message = asRecord(messageValue); + if (!message) continue; + const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + const result = asRecord(message.tool_result); + messages.push({ + role: optionalString(message.role) ?? "", + text: optionalString(message.text) ?? "", + toolCalls: calls.flatMap((callValue) => { + const call = asRecord(callValue); + if (!call) return []; + return [ + { + id: optionalString(call.id) ?? "", + name: optionalString(call.name) ?? "", + args: optionalString(call.args) ?? "", + }, + ]; + }), + toolResult: result + ? { + callId: optionalString(result.call_id) ?? "", + content: optionalString(result.content) ?? "", + isError: booleanFlag(result.is_error), + } + : undefined, + }); + } + return { + sessionId: optionalString(body?.session_id) ?? "", + complete: booleanFlag(body?.complete), + messages, + }; +}