feat: single-source cross-app wire contracts in @boocode/contracts (v2.7.13)
Move all hand-synced cross-app wire contracts into one built workspace package, @boocode/contracts, consumed by server/web/coder/coder-web via workspace:* + a per-subpath exports map. The ws-frames and provider-config Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason, AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are each single-sourced. Deletes the byte-identical copies and their parity tests, fixes a live AgentSessionConfig drift (coder dead copy removed, unified to the web required/nullable shape), removes the dead pending_change WS arms in the fallback SPA, and inverts the build order (contracts builds first) across root build, Dockerfile, and the coder deploy docs. Reverses the shared-package decision declined in v2.5.12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
## Build, deploy, dispatch
|
||||
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. **apps/server must build FIRST.**
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- Build + deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- After `pnpm -C apps/coder build` the host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler shape). Restart, don't re-debug.
|
||||
- `:9502/api/health` is down ~15–20s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.159",
|
||||
"@boocode/server": "workspace:*",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createInferenceRunner } from '@boocode/server/inference';
|
||||
import { createBroker } from '@boocode/server/broker';
|
||||
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { getSkillBody } from '@boocode/server/skills';
|
||||
import {
|
||||
buildSkillInvokeSyntheticFrames,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Parity guard between the two copies of the provider snapshot types:
|
||||
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||
* apps/web/src/api/types.ts (web wire copy)
|
||||
*
|
||||
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||
* assignability check was attempted first (a web-side file importing coder's
|
||||
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||
* import is structurally blocked. This runtime guard FAILS on any field
|
||||
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||
|
||||
function extractBlock(src: string, name: string): string {
|
||||
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||
const block = iface?.[0] ?? alias?.[0];
|
||||
if (!block) throw new Error(`type block '${name}' not found`);
|
||||
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||
return block
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l.length > 0 &&
|
||||
!l.startsWith('//') &&
|
||||
!l.startsWith('/*') &&
|
||||
!l.startsWith('*'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||
// drift anywhere in the snapshot surface is caught.
|
||||
const names = [
|
||||
'ProviderSnapshotStatus',
|
||||
'ProviderSnapshotEntry',
|
||||
'ProviderModel',
|
||||
'ProviderMode',
|
||||
'ThinkingOption',
|
||||
'AgentCommand',
|
||||
];
|
||||
for (const name of names) {
|
||||
it(`${name} is identical in both copies`, () => {
|
||||
expect(
|
||||
extractBlock(webSrc, name),
|
||||
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||
).toBe(extractBlock(coderSrc, name));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveLaunchSpec } from './acp-spawn.js';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web.
|
||||
*/
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentStatus } from './normalize-agent-status.js';
|
||||
|
||||
// The exact slice of Broker we need — accepting just the bound method keeps call
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { createCheckpoint } from './checkpoints.js';
|
||||
|
||||
@@ -5,42 +5,28 @@
|
||||
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
|
||||
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
|
||||
* `{ providers: {} }` (built-ins only, all enabled).
|
||||
*
|
||||
* Schemas are defined once in @boocode/contracts/provider-config and re-exported
|
||||
* here so existing importers (routes, tests, registry) don't need path changes.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ProviderOverrideSchema,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
type ProviderOverride,
|
||||
type CoderProvidersFile,
|
||||
type ProviderConfigPatch,
|
||||
} from '@boocode/contracts/provider-config';
|
||||
|
||||
// Schemas verbatim from design.md §2.2.
|
||||
export const ProviderOverrideSchema = z.object({
|
||||
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().optional(), // default true
|
||||
order: z.number().int().optional(), // UI sort key
|
||||
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
});
|
||||
|
||||
export const CoderProvidersFileSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema).default({}),
|
||||
});
|
||||
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||
|
||||
/**
|
||||
* PATCH body schema (design.md §6.2). A partial providers map where each value
|
||||
* is either a full override object (REPLACES that id's override) or `null`
|
||||
* (DELETES the override → revert to the built-in default). Ids absent from the
|
||||
* patch are left untouched. The route validates the body against this first
|
||||
* (malformed → 422) so a bad shape can never reach the merge/save step.
|
||||
*/
|
||||
export const ProviderConfigPatchSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
|
||||
});
|
||||
|
||||
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
|
||||
export {
|
||||
ProviderOverrideSchema,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
type ProviderOverride,
|
||||
type CoderProvidersFile,
|
||||
type ProviderConfigPatch,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
|
||||
/** Provider snapshot types — re-exported from @boocode/contracts for local consumers. */
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Auto-approve tool permissions when this mode is selected. */
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
export type {
|
||||
ProviderMode,
|
||||
ThinkingOption,
|
||||
ProviderModel,
|
||||
ProviderSnapshotStatus,
|
||||
AgentCommand,
|
||||
ProviderSnapshotEntry,
|
||||
} from '@boocode/contracts/provider-snapshot';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Sql } from '../db.js';
|
||||
import { hostExec } from './host-exec.js';
|
||||
import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
|
||||
export const WORKTREE_BASE = '/tmp/booworktrees';
|
||||
|
||||
@@ -379,22 +380,8 @@ export async function rebaselineWorktreeAfterApply(
|
||||
}
|
||||
|
||||
// ─── Session-delete work-loss guard ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
|
||||
* `atRisk` is the gate the server reads before allowing a session delete.
|
||||
* A git error never silently passes — it forces `atRisk` true and surfaces
|
||||
* the message in `error` (fail-closed).
|
||||
*/
|
||||
export interface RiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
|
||||
unmerged: number; // commits on this branch not in the project default branch
|
||||
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
|
||||
error?: string; // populated on a git failure; presence forces atRisk
|
||||
}
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { WorktreeRiskReport };
|
||||
|
||||
/**
|
||||
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
|
||||
@@ -448,7 +435,7 @@ async function detectDefaultBranchRef(
|
||||
export async function checkWorktreeWorkAtRisk(
|
||||
worktreePath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<RiskReport> {
|
||||
): Promise<WorktreeRiskReport> {
|
||||
// Branch name — also doubles as the "is this still a git worktree?" probe.
|
||||
const br = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// Minimal types for the BooCoder frontend.
|
||||
// Shared DB entities (same schema as BooChat).
|
||||
//
|
||||
// WS wire contracts are single-sourced from @boocode/contracts (the canonical
|
||||
// Zod-backed schema). The DB entity types below (Project/Session/Chat/Message/
|
||||
// ToolCall/ToolResult/PendingChange) are an intentional minimal SPA-local subset
|
||||
// and are NOT cross-app contracts — they stay defined here.
|
||||
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
|
||||
// Re-export the canonical WebSocket frame union (single source of truth). The
|
||||
// coder backend publishes the full frame set; this SPA's reducer handles the
|
||||
// subset it renders and ignores the rest.
|
||||
export type { WsFrame };
|
||||
|
||||
// The error frame's `reason`, single-sourced from the canonical schema's
|
||||
// frame-level reason enum (derived from WsFrame so it cannot drift from the
|
||||
// wire). Distinct from message-metadata's ErrorReason, which is a different set.
|
||||
export type ErrorReason = NonNullable<Extract<WsFrame, { type: 'error' }>['reason']>;
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
@@ -39,7 +56,9 @@ export interface ToolResult {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: boolean;
|
||||
// Canonical wire shape: the failure message string (present only on error),
|
||||
// not a boolean. ToolResultBubble treats it as truthy → renders error styling.
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
|
||||
@@ -96,15 +115,3 @@ export interface PendingChange {
|
||||
created_at: string;
|
||||
applied_at: string | null;
|
||||
}
|
||||
|
||||
// WebSocket frame types (subset of what the coder backend publishes)
|
||||
export type WsFrame =
|
||||
| { type: 'snapshot'; messages: Message[] }
|
||||
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
|
||||
| { type: 'delta'; message_id: string; chat_id: string; content: string }
|
||||
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
|
||||
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
|
||||
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
|
||||
| { type: 'error'; message_id?: string; error: string; reason?: string }
|
||||
| { type: 'pending_change_added'; change: PendingChange }
|
||||
| { type: 'pending_change_updated'; change: PendingChange };
|
||||
|
||||
@@ -5,10 +5,9 @@ import { api } from '@/api/client';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||
}
|
||||
|
||||
export function DiffPane({ sessionId, onPendingChange }: Props) {
|
||||
export function DiffPane({ sessionId }: Props) {
|
||||
const [changes, setChanges] = useState<PendingChange[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -24,27 +23,13 @@ export function DiffPane({ sessionId, onPendingChange }: Props) {
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Initial load
|
||||
// Initial load. Pending changes are delivered over HTTP (list + apply/reject/
|
||||
// rewind below); there is no WS pending-change frame, so the list refreshes on
|
||||
// mount, on the Refresh button, and optimistically as the user acts on it.
|
||||
useEffect(() => {
|
||||
fetchPending();
|
||||
}, [fetchPending]);
|
||||
|
||||
// Listen for WS pending change events
|
||||
useEffect(() => {
|
||||
const unsub = onPendingChange((change) => {
|
||||
setChanges((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === change.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = change;
|
||||
return next;
|
||||
}
|
||||
return [...prev, change];
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
}, [onPendingChange]);
|
||||
|
||||
const pendingChanges = changes.filter((c) => c.status === 'pending');
|
||||
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { Message, WsFrame, PendingChange } from '@/api/types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
|
||||
interface State {
|
||||
messages: Message[];
|
||||
@@ -10,7 +10,9 @@ interface State {
|
||||
function applyFrame(state: State, frame: WsFrame): State {
|
||||
switch (frame.type) {
|
||||
case 'snapshot': {
|
||||
return { ...state, messages: frame.messages };
|
||||
// Canonical SnapshotFrame.messages is opaque (z.array(z.unknown())); the
|
||||
// coder backend sends Message-shaped rows, so cast to the SPA's local type.
|
||||
return { ...state, messages: frame.messages as Message[] };
|
||||
}
|
||||
case 'message_started': {
|
||||
const exists = state.messages.some((m) => m.id === frame.message_id);
|
||||
@@ -18,7 +20,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
const newMsg: Message = {
|
||||
id: frame.message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id,
|
||||
chat_id: frame.chat_id ?? '',
|
||||
role: frame.role,
|
||||
content: '',
|
||||
kind: 'message',
|
||||
@@ -72,7 +74,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
const newMsg: Message = {
|
||||
id: frame.tool_message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id,
|
||||
chat_id: frame.chat_id ?? '',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
kind: 'message',
|
||||
@@ -119,9 +121,12 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
: state.messages;
|
||||
return { ...state, messages: next, error: frame.error };
|
||||
}
|
||||
case 'pending_change_added':
|
||||
case 'pending_change_updated':
|
||||
// These are handled by the pending changes listener, not the message state
|
||||
default:
|
||||
// The canonical WsFrame carries the full set of frames the coder backend
|
||||
// can publish; this SPA only renders the subset handled above and safely
|
||||
// ignores the rest (reasoning_delta, usage, permission_*, agent_*, and the
|
||||
// per-user sidebar frames). pending_change_* frames have no publisher —
|
||||
// pending changes are delivered over HTTP, so there is nothing to handle.
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -134,14 +139,11 @@ interface SessionStreamResult {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
isStreaming: boolean;
|
||||
/** Listeners for pending change frames */
|
||||
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||
}
|
||||
|
||||
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
|
||||
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@@ -172,13 +174,6 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify pending change listeners
|
||||
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
|
||||
for (const cb of pendingListenersRef.current) {
|
||||
cb(frame.change);
|
||||
}
|
||||
}
|
||||
|
||||
setState((s) => applyFrame(s, frame));
|
||||
};
|
||||
|
||||
@@ -213,18 +208,10 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
|
||||
|
||||
const isStreaming = state.messages.some((m) => m.status === 'streaming');
|
||||
|
||||
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
|
||||
pendingListenersRef.current.add(cb);
|
||||
return () => {
|
||||
pendingListenersRef.current.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
connected: state.connected,
|
||||
error: state.error,
|
||||
isStreaming,
|
||||
onPendingChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ export function Session() {
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { messages, connected, isStreaming, onPendingChange } =
|
||||
useSessionStream(sessionId);
|
||||
const { messages, connected, isStreaming } = useSessionStream(sessionId);
|
||||
|
||||
// Get or create a chat for this session
|
||||
useEffect(() => {
|
||||
@@ -78,9 +77,7 @@ export function Session() {
|
||||
connected={connected}
|
||||
/>
|
||||
}
|
||||
diffPane={
|
||||
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
|
||||
}
|
||||
diffPane={<DiffPane sessionId={sessionId} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||
- **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream.
|
||||
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; `ws-frames.test.ts` enforces parity. Don't add raw `broker.publish()`/`publishUser()` calls.
|
||||
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema single-sourced in `@boocode/contracts` (`packages/contracts/src/ws-frames.ts`); the package's `ws-frames.test.ts` enforces schema correctness. Don't add raw `broker.publish()`/`publishUser()` calls.
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported.
|
||||
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map<sessionId, lastHash>` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`).
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"types": "./dist/types/api.d.ts",
|
||||
"default": "./dist/types/api.js"
|
||||
},
|
||||
"./ws-frames": {
|
||||
"types": "./dist/types/ws-frames.d.ts",
|
||||
"default": "./dist/types/ws-frames.js"
|
||||
},
|
||||
"./db": {
|
||||
"types": "./dist/db.d.ts",
|
||||
"default": "./dist/db.js"
|
||||
@@ -81,6 +77,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
|
||||
@@ -140,7 +140,7 @@ async function main() {
|
||||
publish: (sessionId, frame) => {
|
||||
// v1.13.11-b: route through the typed publishFrame so the broker's
|
||||
// Zod gate validates every inference frame before delivery.
|
||||
broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishFrame(sessionId, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||
// frames on the per-session channel. Inference's regular publish path
|
||||
@@ -149,7 +149,7 @@ async function main() {
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishUserFrame(user, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
}
|
||||
);
|
||||
registerMessageRoutes(app, sql, config, broker, {
|
||||
@@ -194,7 +194,7 @@ async function main() {
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
});
|
||||
registerArtifactRoutes(app, sql);
|
||||
@@ -222,7 +222,7 @@ async function main() {
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
@@ -1,159 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
WsFrameSchema,
|
||||
KNOWN_FRAME_TYPES,
|
||||
type WsFrame,
|
||||
} from '../../types/ws-frames.js';
|
||||
} from '@boocode/contracts/ws-frames';
|
||||
import { createBroker } from '../broker.js';
|
||||
|
||||
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
|
||||
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
|
||||
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
|
||||
|
||||
describe('WsFrameSchema (v1.13.11-a)', () => {
|
||||
it('accepts a well-formed chat_status frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'streaming',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown frame type', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'cosmic_ray_strike',
|
||||
chat_id: VALID_UUID_A,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a chat_status frame with invalid status enum', () => {
|
||||
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
|
||||
// should fail validation — that's a drift catcher.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'working',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a UUID field with a non-UUID string', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: 'not-a-uuid',
|
||||
status: 'idle',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative token counts in usage frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: -1,
|
||||
ctx_used: 100,
|
||||
ctx_max: 1000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
|
||||
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'tool_result',
|
||||
tool_message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
tool_call_id: 'call_abc123',
|
||||
output: { whatever: true },
|
||||
truncated: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a compacted frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'compacted',
|
||||
session_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
summary_message_id: VALID_UUID_C,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a session_workspace_updated frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'session_workspace_updated',
|
||||
session_id: VALID_UUID_A,
|
||||
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a message_complete frame with a null model (external coder, no model selected)', () => {
|
||||
// Regression guard: the dispatcher publishes `model: task.model` (string |
|
||||
// null). When null, this MUST validate or publishFrame fail-closes and drops
|
||||
// the whole frame, incl. the status:'complete' transition.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'message_complete',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
model: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
|
||||
// Probe each known type by attempting a minimal valid construction.
|
||||
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
|
||||
for (const type of KNOWN_FRAME_TYPES) {
|
||||
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
|
||||
// We expect FAILURE on every type because we're missing required fields,
|
||||
// but the failure must be ABOUT the missing fields, not about an unknown
|
||||
// type. A "Invalid discriminator value" error means the type isn't in
|
||||
// the union — that's a drift.
|
||||
if (probe.success) continue;
|
||||
const issues = probe.error.issues;
|
||||
const hasInvalidDiscriminator = issues.some(
|
||||
(i) => i.code === 'invalid_union_discriminator',
|
||||
);
|
||||
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ws-frames.ts file mirror parity', () => {
|
||||
it('apps/server and apps/web copies are byte-identical', () => {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const serverPath = resolve(here, '../../../types/ws-frames.ts');
|
||||
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
|
||||
const serverContent = readFileSync(serverPath, 'utf8');
|
||||
const webContent = readFileSync(webPath, 'utf8');
|
||||
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
|
||||
let logErrors: Array<{ obj: unknown; msg: string }>;
|
||||
let mockLog: Parameters<typeof createBroker>[0];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js';
|
||||
import { WsFrameSchema, type WsFrame } from '@boocode/contracts/ws-frames';
|
||||
|
||||
export type Frame = Record<string, unknown> & { type: string };
|
||||
export type Listener = (frame: Frame) => void;
|
||||
|
||||
@@ -25,19 +25,9 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
|
||||
// a delete is blocked because the session's worktree holds work at risk. The
|
||||
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
|
||||
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
export type { WorktreeRiskReport };
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
@@ -198,51 +188,10 @@ export interface ToolResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// v1.8.2: structured reason codes for failed inferences. `error` carries the
|
||||
// human text; `reason` is the machine-readable discriminator the UI matches
|
||||
// on (with `error` as fallback when reason is absent or unrecognized).
|
||||
export type ErrorReason =
|
||||
| 'llm_provider_error'
|
||||
| 'tool_execution_failed'
|
||||
| 'summary_after_cap_failed';
|
||||
|
||||
// v1.8.2 / v1.11.6: shapes stored in messages.metadata. Discriminated on `kind`.
|
||||
// cap_hit — system sentinel emitted when tool budget is exhausted
|
||||
// doom_loop — system sentinel emitted when the model called the same
|
||||
// tool with the same args DOOM_LOOP_THRESHOLD times in a row
|
||||
// mistake_recovery — system sentinel emitted when a run of consecutive
|
||||
// *heterogeneous* tool failures is detected (#12). A nudge
|
||||
// (escalated:false) injects model-facing recovery guidance
|
||||
// and continues; an escalate (escalated:true) stops the
|
||||
// turn after the nudge failed to break the failure run.
|
||||
// error — attached to a failed assistant message so UI can show reason
|
||||
export type MessageMetadata =
|
||||
| {
|
||||
kind: 'cap_hit';
|
||||
used: number;
|
||||
limit: number;
|
||||
agent_name: string | null;
|
||||
can_continue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'doom_loop';
|
||||
tool_name: string;
|
||||
args: Record<string, unknown>;
|
||||
threshold: number;
|
||||
}
|
||||
| {
|
||||
// PINNED CONTRACT (#12) — mirrored byte-for-byte in apps/web/src/api/types.ts.
|
||||
kind: 'mistake_recovery';
|
||||
failure_kinds: string[];
|
||||
count: number;
|
||||
escalated: boolean;
|
||||
can_continue?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error_reason: ErrorReason;
|
||||
error_text: string;
|
||||
};
|
||||
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
|
||||
// @boocode/contracts — edit the package, not here.
|
||||
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
|
||||
export type { ErrorReason, MessageMetadata };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
||||
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
||||
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
||||
// silent protocol drift between publisher and consumer.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
||||
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
||||
// files match. If you change one, change the other.
|
||||
//
|
||||
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
||||
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
||||
// deep payload validation is follow-up work.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- shared primitives -----------------------------------------------------
|
||||
|
||||
const Uuid = z.string().uuid();
|
||||
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
||||
const ToolCallId = z.string().min(1);
|
||||
// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not
|
||||
// strings. The publish sites pass them through unchanged, so the schema must
|
||||
// tolerate both. preprocess converts Date → ISO string before string-validation;
|
||||
// on the web side (where frames arrive via JSON.parse) it's a no-op. Before
|
||||
// this fix, every message_complete / session_updated / chat_updated frame
|
||||
// failed validation and got dropped — symptoms: token tracking blank in UI,
|
||||
// status stuck at 'streaming' tripping the 60s stale-stream banner.
|
||||
const IsoTimestamp = z.preprocess(
|
||||
(v) => (v instanceof Date ? v.toISOString() : v),
|
||||
z.string().min(1),
|
||||
);
|
||||
|
||||
const ChatStatusValue = z.enum([
|
||||
'streaming',
|
||||
'tool_running',
|
||||
'waiting_for_input',
|
||||
'idle',
|
||||
'error',
|
||||
]);
|
||||
|
||||
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
|
||||
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
|
||||
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
|
||||
// dispatcher + permission flow on the per-session channel.
|
||||
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
'doom_loop_summary_failed',
|
||||
'cap_hit',
|
||||
'cap_hit_summary_failed',
|
||||
]);
|
||||
|
||||
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
||||
|
||||
const ToolCallShape = z.object({
|
||||
id: ToolCallId,
|
||||
name: z.string().min(1),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
// Free-form bags: opaque to the frame schema; deep validation is out of
|
||||
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
||||
// payload narrowing is follow-up work). z.unknown() means the consumer
|
||||
// must narrow before reading — TypeScript-side this is fine because every
|
||||
// consumer already operates on the hand-maintained Project / Chat / Session
|
||||
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
||||
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
||||
const OpaqueObject = z.unknown();
|
||||
|
||||
// ---- per-session channel frames --------------------------------------------
|
||||
|
||||
export const SnapshotFrame = z.object({
|
||||
type: z.literal('snapshot'),
|
||||
messages: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const MessageStartedFrame = z.object({
|
||||
type: z.literal('message_started'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
role: MessageRoleValue,
|
||||
});
|
||||
|
||||
export const DeltaFrame = z.object({
|
||||
type: z.literal('delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ReasoningDeltaFrame = z.object({
|
||||
type: z.literal('reasoning_delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallFrame = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call: ToolCallShape,
|
||||
});
|
||||
|
||||
export const ToolResultFrame = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call_id: ToolCallId,
|
||||
output: z.unknown(),
|
||||
truncated: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MessageCompleteFrame = z.object({
|
||||
type: z.literal('message_complete'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
// nullable: external-coder turns carry task.model, which is null when no
|
||||
// model was selected. This frame is published through the same fail-closed
|
||||
// publishFrame, so null MUST validate or the entire frame (incl. the
|
||||
// status:'complete' transition) is dropped.
|
||||
model: z.string().nullable().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UsageFrame = z.object({
|
||||
type: z.literal('usage'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
completion_tokens: z.number().int().nonnegative().nullable(),
|
||||
ctx_used: z.number().int().nonnegative().nullable(),
|
||||
ctx_max: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const MessagesDeletedFrame = z.object({
|
||||
type: z.literal('messages_deleted'),
|
||||
message_ids: z.array(Uuid),
|
||||
chat_id: Uuid.optional(),
|
||||
});
|
||||
|
||||
export const ChatRenamedFrame = z.object({
|
||||
type: z.literal('chat_renamed'),
|
||||
chat_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const CompactedFrame = z.object({
|
||||
type: z.literal('compacted'),
|
||||
session_id: Uuid,
|
||||
chat_id: Uuid,
|
||||
summary_message_id: Uuid,
|
||||
});
|
||||
|
||||
export const ErrorFrame = z.object({
|
||||
type: z.literal('error'),
|
||||
message_id: Uuid.optional(),
|
||||
chat_id: Uuid.optional(),
|
||||
error: z.string(),
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
||||
|
||||
export const ChatStatusFrame = z.object({
|
||||
type: z.literal('chat_status'),
|
||||
chat_id: Uuid,
|
||||
status: ChatStatusValue,
|
||||
at: IsoTimestamp,
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
export const SessionUpdatedFrame = z.object({
|
||||
type: z.literal('session_updated'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const SessionRenamedFrame = z.object({
|
||||
type: z.literal('session_renamed'),
|
||||
session_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SessionCreatedFrame = z.object({
|
||||
type: z.literal('session_created'),
|
||||
session: OpaqueObject,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionArchivedFrame = z.object({
|
||||
type: z.literal('session_archived'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionDeletedFrame = z.object({
|
||||
type: z.literal('session_deleted'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||
// every envelope frame at validation. MUST be mirrored in the server's
|
||||
// byte-identical copy (parity test).
|
||||
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
type: z.literal('chat_created'),
|
||||
chat: OpaqueObject,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUpdatedFrame = z.object({
|
||||
type: z.literal('chat_updated'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
name: z.string().nullable(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const ChatArchivedFrame = z.object({
|
||||
type: z.literal('chat_archived'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUnarchivedFrame = z.object({
|
||||
type: z.literal('chat_unarchived'),
|
||||
chat: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ChatDeletedFrame = z.object({
|
||||
type: z.literal('chat_deleted'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectCreatedFrame = z.object({
|
||||
type: z.literal('project_created'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectArchivedFrame = z.object({
|
||||
type: z.literal('project_archived'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectUnarchivedFrame = z.object({
|
||||
type: z.literal('project_unarchived'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectUpdatedFrame = z.object({
|
||||
type: z.literal('project_updated'),
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectDeletedFrame = z.object({
|
||||
type: z.literal('project_deleted'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
const PermissionOptionShape = z.object({
|
||||
option_id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
export const PermissionResolvedFrame = z.object({
|
||||
type: z.literal('permission_resolved'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
const AgentCommandShape = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AgentCommandsFrame = z.object({
|
||||
type: z.literal('agent_commands'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
commands: z.array(AgentCommandShape),
|
||||
});
|
||||
|
||||
// agent-status-normalize (#10): published by BooCoder on the per-session channel
|
||||
// when an external agent's normalized status changes (turn start/end, permission
|
||||
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
|
||||
// pair and resets on chat switch. `reason` is a free-form discriminator
|
||||
// (turn_start / turn_complete / failed / crashed / permission_request /
|
||||
// permission_resolved).
|
||||
export const AgentStatusUpdatedFrame = z.object({
|
||||
type: z.literal('agent_status_updated'),
|
||||
chat_id: Uuid,
|
||||
agent: z.string().min(1),
|
||||
status: AgentStatusValue,
|
||||
reason: z.string().optional(),
|
||||
at: IsoTimestamp,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
// per-session
|
||||
SnapshotFrame,
|
||||
MessageStartedFrame,
|
||||
DeltaFrame,
|
||||
ReasoningDeltaFrame,
|
||||
ToolCallFrame,
|
||||
ToolResultFrame,
|
||||
MessageCompleteFrame,
|
||||
UsageFrame,
|
||||
MessagesDeletedFrame,
|
||||
ChatRenamedFrame,
|
||||
CompactedFrame,
|
||||
ErrorFrame,
|
||||
PermissionRequestedFrame,
|
||||
PermissionResolvedFrame,
|
||||
AgentCommandsFrame,
|
||||
AgentStatusUpdatedFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
SessionRenamedFrame,
|
||||
SessionCreatedFrame,
|
||||
SessionArchivedFrame,
|
||||
SessionDeletedFrame,
|
||||
SessionWorkspaceUpdatedFrame,
|
||||
ChatCreatedFrame,
|
||||
ChatUpdatedFrame,
|
||||
ChatArchivedFrame,
|
||||
ChatUnarchivedFrame,
|
||||
ChatDeletedFrame,
|
||||
ProjectCreatedFrame,
|
||||
ProjectArchivedFrame,
|
||||
ProjectUnarchivedFrame,
|
||||
ProjectUpdatedFrame,
|
||||
ProjectDeletedFrame,
|
||||
]);
|
||||
|
||||
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
||||
|
||||
// Convenience: the set of known frame types. Useful for the publishFrame
|
||||
// helper to log the offending type name when validation fails. Kept in sync
|
||||
// by hand with the discriminated union above.
|
||||
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'snapshot',
|
||||
'message_started',
|
||||
'delta',
|
||||
'reasoning_delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'message_complete',
|
||||
'usage',
|
||||
'messages_deleted',
|
||||
'chat_renamed',
|
||||
'compacted',
|
||||
'error',
|
||||
'permission_requested',
|
||||
'permission_resolved',
|
||||
'agent_commands',
|
||||
'agent_status_updated',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
'session_created',
|
||||
'session_archived',
|
||||
'session_deleted',
|
||||
'session_workspace_updated',
|
||||
'chat_created',
|
||||
'chat_updated',
|
||||
'chat_archived',
|
||||
'chat_unarchived',
|
||||
'chat_deleted',
|
||||
'project_created',
|
||||
'project_archived',
|
||||
'project_unarchived',
|
||||
'project_updated',
|
||||
'project_deleted',
|
||||
] as const;
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
|
||||
@@ -34,18 +34,8 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
|
||||
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
|
||||
// `reports` field of the 409 body when a delete is blocked.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
@@ -143,49 +133,10 @@ export interface ToolResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// v1.8.2: structured reason codes that flow through error frames / metadata.
|
||||
// `error` text stays human; `reason` is the discriminator the UI matches on.
|
||||
export type ErrorReason =
|
||||
| 'llm_provider_error'
|
||||
| 'tool_execution_failed'
|
||||
| 'summary_after_cap_failed';
|
||||
|
||||
// v1.8.2 / v1.11.6: shapes stored in Message.metadata. Discriminated on `kind`.
|
||||
// cap_hit — sentinel emitted when the tool budget is hit; carries the
|
||||
// budget + agent name + whether Continue is still allowed.
|
||||
// doom_loop — sentinel emitted when the model called the same tool with
|
||||
// the same arguments threshold times in a row.
|
||||
// mistake_recovery — sentinel emitted when the model hit repeated *different*
|
||||
// errors; non-escalated means recovery guidance was injected and
|
||||
// the turn continues, escalated means the turn was stopped.
|
||||
// error — attached to a failed assistant message so the bubble can show
|
||||
// a specific reason on reload (WS error frame is one-shot).
|
||||
export type MessageMetadata =
|
||||
| {
|
||||
kind: 'cap_hit';
|
||||
used: number;
|
||||
limit: number;
|
||||
agent_name: string | null;
|
||||
can_continue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'doom_loop';
|
||||
tool_name: string;
|
||||
args: Record<string, unknown>;
|
||||
threshold: number;
|
||||
}
|
||||
| {
|
||||
kind: 'mistake_recovery';
|
||||
failure_kinds: string[];
|
||||
count: number;
|
||||
escalated: boolean;
|
||||
can_continue?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error_reason: ErrorReason;
|
||||
error_text: string;
|
||||
};
|
||||
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
|
||||
// @boocode/contracts — edit the package, not here.
|
||||
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
|
||||
export type { ErrorReason, MessageMetadata };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -239,80 +190,23 @@ export interface ModelInfo {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
export type {
|
||||
ProviderModel,
|
||||
ProviderMode,
|
||||
ThinkingOption,
|
||||
ProviderSnapshotStatus,
|
||||
AgentCommand,
|
||||
ProviderSnapshotEntry,
|
||||
} from '@boocode/contracts/provider-snapshot';
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
export type {
|
||||
ProviderOverride,
|
||||
CoderProvidersFile,
|
||||
ProviderConfigPatch,
|
||||
} from '@boocode/contracts/provider-config';
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
|
||||
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
|
||||
// (web can't cross-import the coder package — TS6307 on the composite project).
|
||||
export interface ProviderOverride {
|
||||
extends?: 'acp';
|
||||
label?: string;
|
||||
description?: string;
|
||||
command?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
order?: number;
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CoderProvidersFile {
|
||||
providers: Record<string, ProviderOverride>;
|
||||
}
|
||||
|
||||
// PATCH body: a partial providers map. A `null` value deletes that id's
|
||||
// override (revert to built-in default); an object replaces it wholesale.
|
||||
export interface ProviderConfigPatch {
|
||||
providers: Record<string, ProviderOverride | null>;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
// AgentSessionConfig single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { AgentSessionConfig } from '@boocode/contracts/message-metadata';
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
@@ -324,14 +218,6 @@ export interface PermissionPrompt {
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
export interface CoderSendMessageBody {
|
||||
content: string;
|
||||
pane_id: string;
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
||||
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
||||
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
||||
// silent protocol drift between publisher and consumer.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
||||
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
||||
// files match. If you change one, change the other.
|
||||
//
|
||||
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
||||
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
||||
// deep payload validation is follow-up work.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- shared primitives -----------------------------------------------------
|
||||
|
||||
const Uuid = z.string().uuid();
|
||||
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
||||
const ToolCallId = z.string().min(1);
|
||||
// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not
|
||||
// strings. The publish sites pass them through unchanged, so the schema must
|
||||
// tolerate both. preprocess converts Date → ISO string before string-validation;
|
||||
// on the web side (where frames arrive via JSON.parse) it's a no-op. Before
|
||||
// this fix, every message_complete / session_updated / chat_updated frame
|
||||
// failed validation and got dropped — symptoms: token tracking blank in UI,
|
||||
// status stuck at 'streaming' tripping the 60s stale-stream banner.
|
||||
const IsoTimestamp = z.preprocess(
|
||||
(v) => (v instanceof Date ? v.toISOString() : v),
|
||||
z.string().min(1),
|
||||
);
|
||||
|
||||
const ChatStatusValue = z.enum([
|
||||
'streaming',
|
||||
'tool_running',
|
||||
'waiting_for_input',
|
||||
'idle',
|
||||
'error',
|
||||
]);
|
||||
|
||||
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
|
||||
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
|
||||
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
|
||||
// dispatcher + permission flow on the per-session channel.
|
||||
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
'doom_loop_summary_failed',
|
||||
'cap_hit',
|
||||
'cap_hit_summary_failed',
|
||||
]);
|
||||
|
||||
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
||||
|
||||
const ToolCallShape = z.object({
|
||||
id: ToolCallId,
|
||||
name: z.string().min(1),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
// Free-form bags: opaque to the frame schema; deep validation is out of
|
||||
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
||||
// payload narrowing is follow-up work). z.unknown() means the consumer
|
||||
// must narrow before reading — TypeScript-side this is fine because every
|
||||
// consumer already operates on the hand-maintained Project / Chat / Session
|
||||
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
||||
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
||||
const OpaqueObject = z.unknown();
|
||||
|
||||
// ---- per-session channel frames --------------------------------------------
|
||||
|
||||
export const SnapshotFrame = z.object({
|
||||
type: z.literal('snapshot'),
|
||||
messages: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const MessageStartedFrame = z.object({
|
||||
type: z.literal('message_started'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
role: MessageRoleValue,
|
||||
});
|
||||
|
||||
export const DeltaFrame = z.object({
|
||||
type: z.literal('delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ReasoningDeltaFrame = z.object({
|
||||
type: z.literal('reasoning_delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallFrame = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call: ToolCallShape,
|
||||
});
|
||||
|
||||
export const ToolResultFrame = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call_id: ToolCallId,
|
||||
output: z.unknown(),
|
||||
truncated: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MessageCompleteFrame = z.object({
|
||||
type: z.literal('message_complete'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
// nullable: external-coder turns carry task.model, which is null when no
|
||||
// model was selected. This frame is published through the same fail-closed
|
||||
// publishFrame, so null MUST validate or the entire frame (incl. the
|
||||
// status:'complete' transition) is dropped.
|
||||
model: z.string().nullable().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UsageFrame = z.object({
|
||||
type: z.literal('usage'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
completion_tokens: z.number().int().nonnegative().nullable(),
|
||||
ctx_used: z.number().int().nonnegative().nullable(),
|
||||
ctx_max: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const MessagesDeletedFrame = z.object({
|
||||
type: z.literal('messages_deleted'),
|
||||
message_ids: z.array(Uuid),
|
||||
chat_id: Uuid.optional(),
|
||||
});
|
||||
|
||||
export const ChatRenamedFrame = z.object({
|
||||
type: z.literal('chat_renamed'),
|
||||
chat_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const CompactedFrame = z.object({
|
||||
type: z.literal('compacted'),
|
||||
session_id: Uuid,
|
||||
chat_id: Uuid,
|
||||
summary_message_id: Uuid,
|
||||
});
|
||||
|
||||
export const ErrorFrame = z.object({
|
||||
type: z.literal('error'),
|
||||
message_id: Uuid.optional(),
|
||||
chat_id: Uuid.optional(),
|
||||
error: z.string(),
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
||||
|
||||
export const ChatStatusFrame = z.object({
|
||||
type: z.literal('chat_status'),
|
||||
chat_id: Uuid,
|
||||
status: ChatStatusValue,
|
||||
at: IsoTimestamp,
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
export const SessionUpdatedFrame = z.object({
|
||||
type: z.literal('session_updated'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const SessionRenamedFrame = z.object({
|
||||
type: z.literal('session_renamed'),
|
||||
session_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SessionCreatedFrame = z.object({
|
||||
type: z.literal('session_created'),
|
||||
session: OpaqueObject,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionArchivedFrame = z.object({
|
||||
type: z.literal('session_archived'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionDeletedFrame = z.object({
|
||||
type: z.literal('session_deleted'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||
// every envelope frame at validation. MUST be mirrored in the server's
|
||||
// byte-identical copy (parity test).
|
||||
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
type: z.literal('chat_created'),
|
||||
chat: OpaqueObject,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUpdatedFrame = z.object({
|
||||
type: z.literal('chat_updated'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
name: z.string().nullable(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const ChatArchivedFrame = z.object({
|
||||
type: z.literal('chat_archived'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUnarchivedFrame = z.object({
|
||||
type: z.literal('chat_unarchived'),
|
||||
chat: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ChatDeletedFrame = z.object({
|
||||
type: z.literal('chat_deleted'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectCreatedFrame = z.object({
|
||||
type: z.literal('project_created'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectArchivedFrame = z.object({
|
||||
type: z.literal('project_archived'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectUnarchivedFrame = z.object({
|
||||
type: z.literal('project_unarchived'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectUpdatedFrame = z.object({
|
||||
type: z.literal('project_updated'),
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectDeletedFrame = z.object({
|
||||
type: z.literal('project_deleted'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
const PermissionOptionShape = z.object({
|
||||
option_id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
export const PermissionResolvedFrame = z.object({
|
||||
type: z.literal('permission_resolved'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
const AgentCommandShape = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AgentCommandsFrame = z.object({
|
||||
type: z.literal('agent_commands'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
commands: z.array(AgentCommandShape),
|
||||
});
|
||||
|
||||
// agent-status-normalize (#10): published by BooCoder on the per-session channel
|
||||
// when an external agent's normalized status changes (turn start/end, permission
|
||||
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
|
||||
// pair and resets on chat switch. `reason` is a free-form discriminator
|
||||
// (turn_start / turn_complete / failed / crashed / permission_request /
|
||||
// permission_resolved).
|
||||
export const AgentStatusUpdatedFrame = z.object({
|
||||
type: z.literal('agent_status_updated'),
|
||||
chat_id: Uuid,
|
||||
agent: z.string().min(1),
|
||||
status: AgentStatusValue,
|
||||
reason: z.string().optional(),
|
||||
at: IsoTimestamp,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
// per-session
|
||||
SnapshotFrame,
|
||||
MessageStartedFrame,
|
||||
DeltaFrame,
|
||||
ReasoningDeltaFrame,
|
||||
ToolCallFrame,
|
||||
ToolResultFrame,
|
||||
MessageCompleteFrame,
|
||||
UsageFrame,
|
||||
MessagesDeletedFrame,
|
||||
ChatRenamedFrame,
|
||||
CompactedFrame,
|
||||
ErrorFrame,
|
||||
PermissionRequestedFrame,
|
||||
PermissionResolvedFrame,
|
||||
AgentCommandsFrame,
|
||||
AgentStatusUpdatedFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
SessionRenamedFrame,
|
||||
SessionCreatedFrame,
|
||||
SessionArchivedFrame,
|
||||
SessionDeletedFrame,
|
||||
SessionWorkspaceUpdatedFrame,
|
||||
ChatCreatedFrame,
|
||||
ChatUpdatedFrame,
|
||||
ChatArchivedFrame,
|
||||
ChatUnarchivedFrame,
|
||||
ChatDeletedFrame,
|
||||
ProjectCreatedFrame,
|
||||
ProjectArchivedFrame,
|
||||
ProjectUnarchivedFrame,
|
||||
ProjectUpdatedFrame,
|
||||
ProjectDeletedFrame,
|
||||
]);
|
||||
|
||||
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
||||
|
||||
// Convenience: the set of known frame types. Useful for the publishFrame
|
||||
// helper to log the offending type name when validation fails. Kept in sync
|
||||
// by hand with the discriminated union above.
|
||||
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'snapshot',
|
||||
'message_started',
|
||||
'delta',
|
||||
'reasoning_delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'message_complete',
|
||||
'usage',
|
||||
'messages_deleted',
|
||||
'chat_renamed',
|
||||
'compacted',
|
||||
'error',
|
||||
'permission_requested',
|
||||
'permission_resolved',
|
||||
'agent_commands',
|
||||
'agent_status_updated',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
'session_created',
|
||||
'session_archived',
|
||||
'session_deleted',
|
||||
'session_workspace_updated',
|
||||
'chat_created',
|
||||
'chat_updated',
|
||||
'chat_archived',
|
||||
'chat_unarchived',
|
||||
'chat_deleted',
|
||||
'project_created',
|
||||
'project_archived',
|
||||
'project_unarchived',
|
||||
'project_updated',
|
||||
'project_deleted',
|
||||
] as const;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { recordUsage } from './useChatThroughput';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { createWsReconnectToast } from './wsReconnectToast';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
Reference in New Issue
Block a user