Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions web/src/components/transcript/ApprovalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,25 @@ import { newRequestId, request } from "../../state/connection";
import { epochOf, focusedSessionMessages } from "../../state/messages";
import { focusedSession, focusedSessionId } from "../../state/sessions";
import { findPendingApproval } from "../../lib/approvals";
import { classifyFleetInput } from "../../lib/fleet-cards";
import {
dispatchPreview,
hasUnresolved,
type DispatchPreview as DispatchPreviewModel,
} from "../../lib/dispatch-preview";
import { sessionList } from "../../state/sessions";
import type { CollaborationCost, SessionMessage } from "../../protocol/types";
import { formatCollaborationCost } from "../../lib/format";

/**
* Repo/branch/content preview for a send-class fleet dispatch, or null for any
* other tool. Non-fleet approvals are untouched.
*/
function dispatchFor(toolName: string, input: unknown): DispatchPreviewModel | null {
const card = classifyFleetInput(toolName, input);
return card ? dispatchPreview(card, sessionList()) : null;
}

/** Custom event the prompt listens for so "Refine" can focus + hint. */
function focusPromptWithHint(hint: string): void {
window.dispatchEvent(
Expand Down Expand Up @@ -207,6 +223,7 @@ const ApprovalBar: Component = () => {
toolName={snap().toolName}
description={snap().description}
collaborationCost={snap().collaborationCost}
dispatch={dispatchFor(snap().toolName, snap().input)}
isPlanMode={isPlanMode()}
busy={busy()}
onApprove={() => safeApprove(true)}
Expand Down Expand Up @@ -235,11 +252,67 @@ const ApprovalBar: Component = () => {
);
};

/**
* Where a dispatch is actually going — the R3 preview.
*
* Deliberately compact: this sits above the prompt on every dispatch, and a
* block that pushes the buttons off screen gets dismissed rather than read. The
* content is clamped for the same reason; the full brief is in the tool card in
* the transcript above.
*/
const DispatchDetails: Component<{ preview: DispatchPreviewModel }> = (props) => (
<div class="mt-1.5 space-y-1 rounded border border-accent/30 bg-bg/50 px-2 py-1.5">
<For each={props.preview.targets}>
{(t) => (
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px]">
<span class="text-fg">{t.name}</span>
<Show when={t.workdir}>
{(w) => <span class="truncate text-fg-muted">{w()}</span>}
</Show>
<Show when={t.branch}>
{(b) => (
<span class="rounded border border-border bg-bg px-1 text-[10px] text-accent">
{b()}
</span>
)}
</Show>
{/* Never silent: a prompt showing no repo would read as "no repo
involved" rather than "I could not tell you which". The daemon
resolves names itself and may still succeed, so this warns
without blocking. */}
<Show when={t.unresolved}>
<span
class="rounded border border-warn/50 bg-warn/10 px-1 text-[10px] text-warn"
title="No session here matches that name — check the target before approving. The daemon resolves names itself and may still find it."
>
unresolved
</span>
</Show>
</div>
)}
</For>
<Show when={props.preview.content}>
{(c) => (
<p class="line-clamp-3 whitespace-pre-wrap text-[11px] leading-snug text-fg-muted">
{c()}
</p>
)}
</Show>
<Show when={hasUnresolved(props.preview)}>
<p class="text-[11px] text-warn">
One or more targets did not resolve here — verify before approving.
</p>
</Show>
</div>
);

const BinaryBar: Component<{
toolName: string;
description: string;
/** Present only for a send-class fleet dispatch from a collaborative session. */
collaborationCost?: CollaborationCost;
/** Present only for a send-class fleet dispatch — repo/branch/content (R3). */
dispatch?: DispatchPreviewModel | null;
isPlanMode: boolean;
busy: boolean;
onApprove: () => void;
Expand All @@ -263,6 +336,13 @@ const BinaryBar: Component<{
{/* What the goal has already cost, on the button that authorizes more.
Shared formatter so web, Telegram and the TUI show the owner the
same number in the same words. */}
{/* R3: a send-class dispatch is proposed with repo + branch + content
shown, because silent misrouting is the failure that would kill
trust in the conductor. Rendered ABOVE the cost roll-up — where the
instruction is going outranks what it has spent. */}
<Show when={props.dispatch}>
{(d) => <DispatchDetails preview={d()} />}
</Show>
<Show when={props.collaborationCost}>
{(c) => (
<div class="mt-1 truncate font-mono text-[11px] text-warn" title="Rolled up across this collaboration's orchestrator and its live role-children">
Expand Down
125 changes: 125 additions & 0 deletions web/src/lib/dispatch-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";

import { classifyFleetInput } from "./fleet-cards";
import { dispatchPreview, hasUnresolved, resolveTarget } from "./dispatch-preview";
import type { SessionInfo } from "../protocol/types";

const session = (id: string, name: string, over: Partial<SessionInfo> = {}): SessionInfo =>
({ id, name, workdir: `/repo/${name}`, status: "idle", ...over }) as SessionInfo;

const API = session("aaaa1111-2222", "api", {
worktree: { branch: "codeoid/fix-login", path: "/repo/api" },
} as Partial<SessionInfo>);
const WEB = session("bbbb3333-4444", "web");
const SESSIONS = [API, WEB];

const card = (verb: string, input: unknown) => classifyFleetInput(`mcp__codeoid_fleet__${verb}`, input)!;

describe("resolveTarget", () => {
it("prefers an exact name — how the conductor is told to address targets", () => {
expect(resolveTarget("api", SESSIONS)?.id).toBe(API.id);
});

it("accepts an id and an unambiguous id prefix", () => {
expect(resolveTarget(API.id, SESSIONS)?.name).toBe("api");
expect(resolveTarget("aaaa1111", SESSIONS)?.name).toBe("api");
});

it("resolves an AMBIGUOUS prefix to nothing rather than guessing", () => {
// Picking one would be a coin flip presented as a fact, on the prompt
// whose whole job is to prevent misrouting.
const twins = [session("dup1", "a"), session("dup2", "b")];
expect(resolveTarget("dup", twins)).toBeNull();
});

it("prefers an exact name over an id prefix that also matches", () => {
const odd = [session("zzz", "shared"), session("shared-id", "other")];
expect(resolveTarget("shared", odd)?.id).toBe("zzz");
});

it("returns null for a name nothing matches", () => {
expect(resolveTarget("ghost", SESSIONS)).toBeNull();
});
});

describe("dispatchPreview — send-class", () => {
it("shows repo and branch for a send, per R3", () => {
const p = dispatchPreview(
card("fleet_send", { session: "api", message: "run the linter" }),
SESSIONS,
)!;
expect(p.kind).toBe("send");
expect(p.content).toBe("run the linter");
expect(p.targets).toHaveLength(1);
expect(p.targets[0]).toMatchObject({
name: "api",
workdir: "/repo/api",
branch: "codeoid/fix-login",
unresolved: false,
});
expect(hasUnresolved(p)).toBe(false);
});

it("reports a branchless session without inventing one", () => {
const p = dispatchPreview(card("fleet_send", { session: "web", message: "hi" }), SESSIONS)!;
expect(p.targets[0]!.workdir).toBe("/repo/web");
expect(p.targets[0]!.branch).toBeNull();
});

it("FLAGS a target that resolves to nothing", () => {
// The case where an owner is most at risk of approving the wrong thing. A
// prompt that quietly shows no repo reads as "no repo involved" rather
// than "I could not tell you".
const p = dispatchPreview(card("fleet_send", { session: "ghost", message: "x" }), SESSIONS)!;
expect(p.targets[0]!.unresolved).toBe(true);
expect(hasUnresolved(p)).toBe(true);
});

it("expands every member of a panel", () => {
const p = dispatchPreview(
card("fleet_panel", { sessions: ["api", "web", "ghost"], message: "review" }),
SESSIONS,
)!;
expect(p.targets.map((t) => t.name)).toEqual(["api", "web", "ghost"]);
expect(p.targets.map((t) => t.unresolved)).toEqual([false, false, true]);
expect(p.content).toBe("review");
});

it("handles an interrupt, which carries a target but no content", () => {
const p = dispatchPreview(card("fleet_interrupt", { session: "api" }), SESSIONS)!;
expect(p.targets[0]!.workdir).toBe("/repo/api");
expect(p.content).toBeNull();
});

it("survives a dispatch with no target at all", () => {
const p = dispatchPreview(card("fleet_send", { message: "orphan" }), SESSIONS)!;
expect(p.targets).toEqual([]);
expect(hasUnresolved(p)).toBe(false);
});
});

describe("dispatchPreview — spawn", () => {
it("shows the workdir being created in, and does not cry wolf about resolution", () => {
const p = dispatchPreview(
card("fleet_spawn", { workdir: "/repo/new", shape: "scout", task: "investigate" }),
SESSIONS,
)!;
expect(p.kind).toBe("spawn");
expect(p.targets[0]).toMatchObject({ name: "scout", workdir: "/repo/new", unresolved: false });
expect(p.content).toBe("investigate");
// There is no existing session to resolve, so flagging it would fire on
// every spawn.
expect(hasUnresolved(p)).toBe(false);
});
});

describe("dispatchPreview — non-dispatch", () => {
it("returns null for a read verb, which has nothing to propose", () => {
expect(dispatchPreview(card("fleet_find", { query: "x" }), SESSIONS)).toBeNull();
expect(dispatchPreview(card("machine_map", {}), SESSIONS)).toBeNull();
});

it("returns null for an unrecognised verb rather than treating it as a send", () => {
expect(dispatchPreview(card("fleet_detonate", {}), SESSIONS)).toBeNull();
});
});
137 changes: 137 additions & 0 deletions web/src/lib/dispatch-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* What a fleet dispatch is about to touch — repo, branch, target session.
*
* conductor-design R3 is the reason this exists: a send-class action to an
* existing session "first proposes it with **repo + branch + content shown**,
* and acts only on confirm". Silent misrouting is named there as the one
* failure that would kill trust in the conductor, so the approval prompt has to
* show the owner where the instruction is actually going — not just the tool's
* own words for it.
*
* Pure: resolving a target name to a session is the decision worth testing.
*/

import type { FleetCard } from "./fleet-cards";
import type { SessionInfo } from "../protocol/types";

export interface DispatchTarget {
/** The name the conductor used, exactly as it will be dispatched. */
name: string;
/** The session it resolves to, or null when nothing matches. */
session: SessionInfo | null;
/** Absolute workdir of the resolved session. */
workdir: string | null;
/** Worktree branch, when the session has one. */
branch: string | null;
/**
* True when the name matched nothing this client can see.
*
* Surfaced rather than hidden: an unresolvable target is EXACTLY the case
* where an owner is at risk of approving the wrong thing, and a prompt that
* quietly shows no repo reads as "no repo involved" rather than "I could not
* tell you". The daemon resolves names itself and may still succeed — the
* client's view can legitimately be stale — so this is a warning, never a
* reason to block the approval.
*/
unresolved: boolean;
}

export interface DispatchPreview {
/** `spawn` creates a new worker; `send` routes into sessions that already exist. */
kind: "spawn" | "send";
targets: DispatchTarget[];
/** The instruction or brief being delivered, when the card carries one. */
content: string | null;
}

/**
* Resolve a session reference the way a human reading the prompt would.
*
* The conductor is told to name targets by NAME so the owner can verify the
* repo at a glance, but its own tools also accept an id or id prefix — so all
* three are matched here. An exact name wins over a prefix: two sessions can
* share a prefix, and silently preferring the wrong one is the misrouting R3
* exists to prevent.
*/
export function resolveTarget(
ref: string,
sessions: readonly SessionInfo[],
): SessionInfo | null {
const exactName = sessions.find((s) => s.name === ref);
if (exactName) return exactName;
const exactId = sessions.find((s) => s.id === ref);
if (exactId) return exactId;

const byPrefix = sessions.filter((s) => s.id.startsWith(ref));
// An ambiguous prefix resolves to NOTHING. Picking one would be a coin flip
// presented as a fact, on the prompt whose job is to prevent exactly that.
return byPrefix.length === 1 ? byPrefix[0]! : null;
}

function describe(name: string, sessions: readonly SessionInfo[]): DispatchTarget {
const session = resolveTarget(name, sessions);
return {
name,
session,
workdir: session?.workdir ?? null,
branch: session?.worktree?.branch ?? null,
unresolved: session === null,
};
}

/** Read a field off a card by label, or null. */
function field(card: FleetCard, label: string): string | null {
return card.fields.find((f) => f.label === label)?.value ?? null;
}

/**
* Build the preview for a send-class dispatch, or null when the card is not
* one (a read verb has nothing to propose — it already ran).
*/
export function dispatchPreview(
card: FleetCard,
sessions: readonly SessionInfo[],
): DispatchPreview | null {
if (!card.sendClass) return null;

if (card.verb === "fleet_spawn") {
// A spawn has no existing session to verify — the workdir IS the thing to
// check, and it comes straight off the card.
const workdir = field(card, "workdir");
return {
kind: "spawn",
targets: [
{
name: field(card, "shape") ?? "worker",
session: null,
workdir,
branch: null,
// Not "unresolved": there is nothing to resolve yet, and flagging it
// would cry wolf on every spawn.
unresolved: false,
},
],
content: field(card, "task"),
};
}

// send / interrupt / panel all route into sessions that already exist.
const raw = field(card, "sessions") ?? field(card, "target");
const names = raw
? raw
.split(",")
.map((n) => n.trim())
.filter((n) => n.length > 0)
: [];

return {
kind: "send",
targets: names.map((n) => describe(n, sessions)),
content: field(card, "message"),
};
}

/** True when any target could not be resolved — drives the prompt's warning. */
export function hasUnresolved(preview: DispatchPreview): boolean {
return preview.targets.some((t) => t.unresolved);
}
Loading
Loading