* Web-facing message DTO + reducer.
*
* Live `WebGatewayEvent` and historical transcript replays are projected onto
* the same `WebMessage[]` shape so the React UI does not have to branch on
* "live vs history" code paths.
*/
import type { WebGatewayEvent } from "./protocol.js";
function normalizeToolDisplayName(name: string): string {
const aliases: Record<string, string> = {
agent: "Task",
ask_user_question: "AskUserQuestion",
bash: "Bash",
edit_file: "Edit",
glob: "Glob",
grep: "Grep",
read_file: "Read",
write_file: "Write",
};
if (aliases[name]) return aliases[name];
if (name === "todo_write") return "TodoWrite";
if (name === "todo_read") return "TodoRead";
return name;
}
function isPlanModeToolDenyText(text: unknown): boolean {
return typeof text === "string" && /plan mode denies side-effecting tool\b/i.test(text);
}
function normalizeToolErrorCode(errorCode: string | undefined, resultPreview: unknown): string | undefined {
if (isPlanModeToolDenyText(resultPreview)) return "plan_mode_denied";
return errorCode;
}
export type WebMessageRole =
| "user"
| "assistant"
| "tool"
| "system"
| "permission"
| "error";
export type WebMessageKind =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "permission_request"
| "elicitation_request"
| "status"
| "complete"
| "interrupted"
| "error"
| "structured_output"
| "compact_boundary";
export type WebMessage = {
id: string;
sessionKey: string;
projectKey?: string;
createdAt: string;
provider: "pilotdeck" | (string & {});
role: WebMessageRole;
kind: WebMessageKind;
toolCallId?: string;
toolName?: string;
requestId?: string;
ok?: boolean;
text?: string;
images?: Array<{
data: string;
name?: string;
mimeType?: string;
}>;
* `PilotDeckToolErrorCode` of the underlying failure when
* `kind === 'tool_result'` and `ok === false`. Empty for non-error or
* non-tool-result frames. See `chatPermissions.ts` for how the host UI
* uses this.
*/
errorCode?: string;
payload?: unknown;
source: "live" | "history";
finishReason?: string;
usage?: Record<string, number>;
};
export type WebMessageReducerOptions = {
sessionKey: string;
projectKey?: string;
now?: () => Date;
newId?: () => string;
};
export type WebMessageReducerState = {
messages: WebMessage[];
currentAssistantId?: string;
currentThinkingId?: string;
toolMessageByCallId: Record<string, string>;
};
export function createWebMessageReducerState(): WebMessageReducerState {
return {
messages: [],
toolMessageByCallId: {},
};
}
export function applyWebGatewayEvent(
state: WebMessageReducerState,
event: WebGatewayEvent,
options: WebMessageReducerOptions,
): WebMessageReducerState {
const now = options.now ?? (() => new Date());
const newId = options.newId ?? defaultNewId;
const stamp = now().toISOString();
switch (event.type) {
case "turn_started":
return {
...state,
currentAssistantId: undefined,
currentThinkingId: undefined,
};
case "assistant_text_delta": {
if (!event.text) {
return state;
}
if (state.currentAssistantId) {
return {
...state,
messages: state.messages.map((m) =>
m.id === state.currentAssistantId
? { ...m, text: `${m.text ?? ""}${event.text}` }
: m,
),
};
}
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "assistant",
kind: "text",
text: event.text,
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentAssistantId: id,
};
}
case "assistant_thinking_delta": {
if (!event.text) {
return state;
}
if (state.currentThinkingId) {
return {
...state,
messages: state.messages.map((m) =>
m.id === state.currentThinkingId
? { ...m, text: `${m.text ?? ""}${event.text}` }
: m,
),
};
}
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "assistant",
kind: "thinking",
text: event.text,
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentThinkingId: id,
};
}
case "tool_call_started": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "tool",
kind: "tool_use",
toolCallId: event.toolCallId,
toolName: normalizeToolDisplayName(event.name),
text: event.argsPreview,
source: "live",
};
return {
...state,
messages: [...state.messages, message],
toolMessageByCallId: {
...state.toolMessageByCallId,
[event.toolCallId]: id,
},
currentAssistantId: undefined,
};
}
case "tool_call_finished": {
const normalizedErrorCode = normalizeToolErrorCode(event.errorCode, event.resultPreview);
const eventImages =
Array.isArray(event.images) && event.images.length > 0
? event.images.map((image) => ({
data: `data:${image.mimeType};base64,${image.data}`,
mimeType: image.mimeType,
}))
: undefined;
const matchedId = state.toolMessageByCallId[event.toolCallId];
if (matchedId) {
return {
...state,
messages: state.messages.map((m) =>
m.id === matchedId
? {
...m,
kind: "tool_result",
ok: event.ok,
text: event.resultPreview ?? m.text,
...(eventImages ? { images: eventImages } : {}),
...(normalizedErrorCode && { errorCode: normalizedErrorCode }),
}
: m,
),
};
}
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "tool",
kind: "tool_result",
toolCallId: event.toolCallId,
ok: event.ok,
text: event.resultPreview,
...(eventImages ? { images: eventImages } : {}),
...(normalizedErrorCode && { errorCode: normalizedErrorCode }),
source: "live",
};
return {
...state,
messages: [...state.messages, message],
};
}
case "permission_request": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "permission",
kind: "permission_request",
requestId: event.requestId,
toolName: event.toolName,
payload: event.payload,
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentAssistantId: undefined,
};
}
case "elicitation_request": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "system",
kind: "elicitation_request",
requestId: event.requestId,
toolCallId: event.toolCallId,
toolName: event.toolName,
payload: {
questions: event.questions,
previewFormat: event.previewFormat,
metadata: event.metadata,
},
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentAssistantId: undefined,
};
}
case "elicitation_cancelled":
return {
...state,
messages: state.messages.map((m) =>
m.kind === "elicitation_request" && m.requestId === event.requestId
? { ...m, kind: "status", role: "system", text: "elicitation cancelled" }
: m,
),
};
case "structured_output": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "system",
kind: "structured_output",
payload: event.payload,
source: "live",
};
return { ...state, messages: [...state.messages, message] };
}
case "plan_mode_changed": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "system",
kind: "status",
text: `mode → ${event.mode}`,
source: "live",
};
return { ...state, messages: [...state.messages, message] };
}
case "turn_completed": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "system",
kind: "complete",
usage: event.usage,
finishReason: event.finishReason,
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentAssistantId: undefined,
currentThinkingId: undefined,
};
}
case "error": {
const id = newId();
const message: WebMessage = {
id,
sessionKey: options.sessionKey,
projectKey: options.projectKey,
createdAt: stamp,
provider: "pilotdeck",
role: "error",
kind: "error",
text: event.message,
payload: { code: event.code, recoverable: event.recoverable },
source: "live",
};
return {
...state,
messages: [...state.messages, message],
currentAssistantId: undefined,
currentThinkingId: undefined,
};
}
}
return state;
}
function defaultNewId(): string {
const c =
typeof globalThis !== "undefined" && (globalThis as unknown as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === "function") {
return c.randomUUID();
}
return `web-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}