rowboat/apps/x/packages/shared/src/code-mode.ts
Ramnique Singh 3bbed55735 feat(x): ephemeral CodeRunFeed for codex streaming + settle-time durable batch
The runs->turns migration broke codex live streaming in copilot chat: the
turns bridge's publish shim forwarded only tool-output-stream and silently
dropped code-run-event / code-run-permission-request (the latter would
deadlock a turn under policy 'ask'). An interim fix persisted every stream
event as durable tool_progress, but that wrote each text chunk to the turn
file — unsustainable.

Final architecture — live and durable paths split:

- Live (ephemeral, bypasses the turn runtime): code_agent_run broadcasts
  each ACP event on a new CodeRunFeed (core DI singleton), forwarded by
  main over a dedicated codeRun:events channel, buffered per toolCallId in
  a module-level renderer store and rendered by CodingRunBlock. The buffer
  survives session switches; nothing is persisted.

- Durable (one line per run): when the run settles (success, error, or
  cancel), code_agent_run publishes a single code-run-events-batch with
  the whole ordered timeline, consecutive same-role message chunks
  coalesced (display-lossless — the timeline concatenates them anyway).
  The turns bridge maps it to tool_progress {kind:'code-run-events'};
  turn-view derives the replay timeline from it, so reloads keep history.

- Permissions stay durable per-ask (request + resolved marker): the
  renderer overlay resets on session switch, so an ephemeral-only ask
  would strand a blocked turn with no card to answer. Pending = requests
  minus resolutions (handles concurrent asks), cleared on tool result.

The legacy code-section path (runs bus per-event) is untouched; its
per-event ctx.publish remains and is a no-op under the turns shim.

Also: repaired the two code_agent_run tests broken by the earlier cwd
existence check (they used a nonexistent /repo), and added coverage for
feed broadcast, batch coalescing, partial-batch-on-failure, bridge
durability routing, and pending-permission derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:04:33 +05:30

83 lines
3.2 KiB
TypeScript

import z from "zod";
// Shared zod schemas for the ACP code-mode engine. Single source of truth: the
// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent
// variants that carry these to the renderer.
export const CodingAgent = z.enum(["claude", "codex"]);
export type CodingAgent = z.infer<typeof CodingAgent>;
// How the permission broker answers the agent's requests before any per-tool
// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of
// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag).
export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]);
export type ApprovalPolicy = z.infer<typeof ApprovalPolicy>;
export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]);
export type PermissionDecision = z.infer<typeof PermissionDecision>;
// What the UI needs to render a permission card.
export const PermissionAsk = z.object({
toolCallId: z.string().optional(),
title: z.string(),
kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read"
isRead: z.boolean(),
});
export type PermissionAsk = z.infer<typeof PermissionAsk>;
// Normalized per-run stream items. The engine maps raw ACP session/update
// notifications onto this union; the renderer renders them.
export const CodeRunEvent = z.discriminatedUnion("type", [
// role distinguishes the agent's own output from replayed user turns
// (loadSession streams the whole prior conversation back on resume).
z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }),
z.object({ type: z.literal("thought") }),
z.object({
type: z.literal("tool_call"),
id: z.string().optional(),
title: z.string().optional(),
kind: z.string().optional(),
status: z.string().optional(),
}),
z.object({
type: z.literal("tool_call_update"),
id: z.string().optional(),
status: z.string().optional(),
diffs: z.array(z.string()),
}),
z.object({
type: z.literal("plan"),
entries: z.array(z.object({
content: z.string(),
status: z.string().optional(),
priority: z.string().optional(),
})),
}),
z.object({
type: z.literal("usage"),
used: z.number().nonnegative(),
size: z.number().positive(),
}),
z.object({
type: z.literal("permission"),
ask: PermissionAsk,
decision: z.union([PermissionDecision, z.literal("cancelled")]),
auto: z.boolean(),
}),
z.object({ type: z.literal("other"), sessionUpdate: z.string() }),
]);
export type CodeRunEvent = z.infer<typeof CodeRunEvent>;
export const RunPromptResult = z.object({
stopReason: z.string(),
sessionId: z.string(),
});
export type RunPromptResult = z.infer<typeof RunPromptResult>;
// One item on the ephemeral CodeRunFeed (`codeRun:events` broadcast): a live
// code-run event tagged with the tool call it belongs to. Fire-and-forget —
// the durable record is the code-run-events-batch written when the run settles.
export type CodeRunFeedEvent = {
toolCallId: string;
event: CodeRunEvent;
};