Skip to content
Closed
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
5 changes: 2 additions & 3 deletions scripts/harness-canary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ Six scenarios per agent. Only `sends` makes model turns: four per agent.
| `start-exit` | samples every 150 ms while starting and after exiting | no unread card |

The message kinds live in `messages.ts`: the 14 hand kinds of 2026-09-26, plus `15-rule`, a
pasted `────` line. claude, codex and opencode are read with their adapters. pi has no adapter. For
it a draft passes when its words show on the raw mirror, and a send goes through the one-step path
the phone uses.
pasted `────` line. claude, codex, opencode and pi are read with their adapters. For
each, drafts and sends go through type-then-verify.

### The journal (spec M41/05)

Expand Down
2 changes: 1 addition & 1 deletion scripts/harness-canary/agents/pi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// pi in the canary, with its default model and thinking off (`--thinking off`, a flag, cheaper).
// Collie has no adapter for pi: raw mirror, one-step send.
// Collie has a registered adapter for pi (harness/pi): Tier 1 chrome plus Tier-2 dialog lifts.
//
// `--no-session` WAS here, so a run left no session file behind. It was dropped on 2026-09-30, when
// spec 05 gave the canary a journal check, for three reasons and not for convenience:
Expand Down
11 changes: 11 additions & 0 deletions scripts/harness-canary/readers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export interface Readers {
adapterFor(agent: string): Adapter | undefined;
draftCarriesSend(sent: string, draft: string | null): boolean;
sendGuardedReply(paneId: string, text: string, agent: string): Promise<ReplyOutcome>;
/**
* Stamp a successful read into the readers' own lib/liveness.ts (M46). Absent when the checkout
* predates it (`--readers` at v1.13.1), where the reply action has no liveness gate to satisfy.
*/
readonly markLive?: (paneId: string) => void;
}

interface LivenessModule {
markLive(paneId: string): void;
}

interface AnsiModule {
Expand Down Expand Up @@ -69,6 +78,7 @@ export async function loadReaders(root: string): Promise<Readers> {
const blocks: BlocksModule = await import(join(lib, "blocks.ts"));
const harness: HarnessModule = await import(join(lib, "harness", "index.ts"));
const reply: ReplyModule = await import(join(lib, "reply-action.ts"));
const liveness: LivenessModule | undefined = await import(join(lib, "liveness.ts")).catch(() => undefined);
return {
root,
parse: (text) => blocks.splitLines(ansi.parseAnsi(text)),
Expand All @@ -77,5 +87,6 @@ export async function loadReaders(root: string): Promise<Readers> {
adapterFor: (agent) => harness.adapterFor(agent),
draftCarriesSend: (sent, draft) => reply.draftCarriesSend(sent, draft),
sendGuardedReply: (paneId, text, agent) => reply.sendGuardedReply({ paneId, text, agent }),
markLive: liveness === undefined ? undefined : (paneId) => liveness.markLive(paneId),
};
}
2 changes: 2 additions & 0 deletions scripts/harness-canary/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ async function main(options: CanaryOptions): Promise<number> {
const transport = installTransport(session, join(project, ".collie-home-unused"));
audit = transport.audit;
const readers = await loadReaders(options.readers);
// A successful read makes the pane live, as the client's `fetchPane` does (M46).
if (readers.markLive !== undefined) transport.onLiveRead(readers.markLive);
// The journal adapters are THIS checkout's, never `--readers`': that flag swaps the web/src
// screen readers so an older checkout can be shown missing what it missed, and the bridge side
// of the canary has always been this checkout's (README § "Proof against 1.13.1").
Expand Down
15 changes: 14 additions & 1 deletion scripts/harness-canary/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface Transport {
readScreen(paneId: string): Promise<string>;
/** The audit lines the bridge's reply handler wrote, in order. */
readonly audit: readonly string[];
/**
* Who hears of a successful pane read (a 200 or a 304), the way the client's `fetchPane` stamps
* lib/liveness.ts. Without it M46's isLive gate in `sendGuardedReply` refuses every canary send.
*/
onLiveRead(fn: (paneId: string) => void): void;
}

/**
Expand Down Expand Up @@ -65,14 +70,19 @@ export function installTransport(session: CanarySession, home: string): Transpor
},
});

let liveRead: ((paneId: string) => void) | undefined;
const canaryFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : String(input), ORIGIN);
const match = PANE_PATH.exec(url.pathname);
if (match === null) throw new Error(`canary transport: ${url.pathname} is not a pane route`);
const paneId = decodeURIComponent(match[1]!);
if (!session.isOwnedPane(paneId)) throw new Error(`canary transport: refusing pane ${paneId}, not the canary's`);
const req = new Request(url, init);
if (match[2] === undefined && req.method === "GET") return readPane(mux, cfg, paneId, url, req);
if (match[2] === undefined && req.method === "GET") {
const res = await readPane(mux, cfg, paneId, url, req);
if (res.status === 200 || res.status === 304) liveRead?.(paneId);
return res;
}
if (match[2] === "reply" && req.method === "POST") {
return replyPane(mux, cfg, paneId, req, log, CANARY_DEVICE, CANARY_SESSION);
}
Expand All @@ -82,6 +92,9 @@ export function installTransport(session: CanarySession, home: string): Transpor

return {
audit,
onLiveRead(fn) {
liveRead = fn;
},
async readScreen(paneId) {
const res = await canaryFetch(`/api/pane/${encodeURIComponent(paneId)}`);
const body = await res.text();
Expand Down
89 changes: 84 additions & 5 deletions web/src/components/agent-chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { http, HttpResponse } from "msw";
import { createMemoryRouter, RouterProvider, useParams, useRevalidator } from "react-router";

import { __resetConnectionHealth } from "@/lib/connection-health";
import * as harness from "@/lib/harness";

// Mock the race guard at AgentChat's seam so the frozen-revision tests can observe exactly what
// `detectedRevision` the tap handler passes (the guard's own behaviour is covered in
Expand Down Expand Up @@ -141,6 +142,84 @@ describe("AgentChat — reply flow", () => {
});
});

describe("AgentChat — lifted dialog typing (dialogAcceptsTyping)", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("refuses to send while a lifted dialog is on screen when the adapter lacks the probe", async () => {
const user = userEvent.setup();
renderChat({ text: MENU_TEXT });
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
const box = screen.getByPlaceholderText(/type a reply/i);

await user.type(box, "typed answer");
await user.click(screen.getByRole("button", { name: "Send" }));

expect(await screen.findByText(/a dialog is waiting/i)).toBeInTheDocument();
expect(box).toHaveValue("typed answer");
});

it("refuses to send while a lifted dialog is on screen when the probe answers false", async () => {
const user = userEvent.setup();
const real = harness.adapterFor("claude")!;
vi.spyOn(harness, "adapterFor").mockReturnValue({
...real,
dialogAcceptsTyping: () => false,
});
renderChat({ text: MENU_TEXT });
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
const box = screen.getByPlaceholderText(/type a reply/i);

await user.type(box, "typed answer");
await user.click(screen.getByRole("button", { name: "Send" }));

expect(await screen.findByText(/a dialog is waiting/i)).toBeInTheDocument();
expect(box).toHaveValue("typed answer");
});

it("allows sending through normal type-then-verify path when the probe answers true on current lines", async () => {
const user = userEvent.setup();
const real = harness.adapterFor("claude")!;
const probe = vi.fn().mockReturnValue(true);
vi.spyOn(harness, "adapterFor").mockReturnValue({
...real,
dialogAcceptsTyping: probe,
});
renderChat({ text: MENU_TEXT });
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
const box = screen.getByPlaceholderText(/type an answer/i);

await user.type(box, "typed answer");
await user.click(screen.getByRole("button", { name: "Send" }));

await waitFor(() => expect(box).toHaveValue(""));
expect(screen.queryByText(/a dialog is waiting/i)).toBeNull();
expect(probe).toHaveBeenCalled();
});

it("blocks send when probe answers true but preflight composerReady returns false", async () => {
const user = userEvent.setup();
const real = harness.adapterFor("claude")!;
vi.spyOn(harness, "adapterFor").mockReturnValue({
...real,
dialogAcceptsTyping: () => true,
composerReady: () => false,
});
renderChat({ text: MENU_TEXT });
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
const box = screen.getByPlaceholderText(/type an answer/i);

await user.type(box, "typed answer");
await user.click(screen.getByRole("button", { name: "Send" }));

await waitFor(() =>
expect(screen.getByRole("button", { name: /type anyway/i })).toBeInTheDocument(),
);
expect(screen.queryByText(/a dialog is waiting/i)).toBeNull();
});
});

// Echoes the space passed via navigation state, so a test can assert the header lands on the space
// overview ("/") for the right workspace.
function SpaceOverviewSentinel() {
Expand Down Expand Up @@ -4192,18 +4271,18 @@ describe("AgentChat — the model label and a footer that already names it", ()
expect(tag(container)?.textContent).toContain("gpt-5.6-sol");
});

it("pi: no adapter lifts its footer, so the raw mirror's last rows are judged", () => {
it("hermes: no adapter lifts its footer, so the raw mirror's last rows are judged", () => {
terminal();
const footer = [
"hello from pi",
"hello from hermes",
"",
"~/webapp (main)",
"↑1.2k ↓300 $0.012 4.5%/200k (auto) (anthropic) claude-sonnet-4-5 • medium",
].join("\n");
const named = renderChat({ text: footer, agent: on("pi", "anthropic:claude-sonnet-4-5") });
const named = renderChat({ text: footer, agent: on("hermes", "anthropic:claude-sonnet-4-5") });
expect(tag(named.container)).toBeNull();
cleanup();
const other = renderChat({ text: footer, agent: on("pi", "anthropic:claude-opus-5-5") });
const other = renderChat({ text: footer, agent: on("hermes", "anthropic:claude-opus-5-5") });
expect(tag(other.container)?.textContent).toContain("Opus 5.5");
});

Expand All @@ -4223,7 +4302,7 @@ describe("AgentChat — the model label and a footer that already names it", ()
const footer = "~/webapp (main)\n(anthropic) claude-sonnet-4-5 • medium";
const { container } = renderChat({
text: footer,
agent: on("pi", "anthropic:claude-sonnet-4-5", { hasSession: true }),
agent: on("hermes", "anthropic:claude-sonnet-4-5", { hasSession: true }),
});
expect(await screen.findByText("what changed today?")).toBeInTheDocument();
expect(tag(container)?.textContent).toContain("Sonnet 4.5");
Expand Down
14 changes: 14 additions & 0 deletions web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,19 @@ export function AgentChat({
() => blocks.some((b) => b.kind === "unread-dialog"),
[blocks],
);
// Whether the lifted dialog on screen accepts typed answers through the composer. Opt-in per
// adapter: true only while a dialog is on screen, grammars are on, and the adapter's
// `dialogAcceptsTyping` answers true on the current display lines. A throw counts as no.
const dialogAcceptsTyping = useMemo(() => {
if (!dialogPresent || !grammarsOn) return false;
const probe = adapterFor(agent?.agent)?.dialogAcceptsTyping;
if (!probe) return false;
try {
return Boolean(probe(splitLines(parseAnsi(display))));
} catch {
return false;
}
}, [dialogPresent, grammarsOn, agent?.agent, display]);

// Both are threaded to the composer: the RAW value (live) plus a stabilised one. extractInputDraft
// is stateless, so it can't distinguish a stranded draft from the ~350ms flash where our OWN
Expand Down Expand Up @@ -2688,6 +2701,7 @@ export function AgentChat({
hostBlock={hostBlock}
dialogPresent={dialogPresent}
dialogUnread={dialogUnread}
dialogAcceptsTyping={dialogAcceptsTyping}
text={text}
terminalDraft={terminalDraft}
rawTerminalDraft={rawTerminalDraft}
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/ansi-output.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ describe("mirror line wrapping", () => {
const text = `ordinary prose\n${rule}\n`;
const visibleText = `ordinary prose\n${visibleRule}\n`;
const query = `${url} ─`; // crosses the label → tail split introduced by structural refinement
const { container, rerender } = render(<AnsiOutput text={text} query={query} currentMatch={0} agent="pi" />);
const { container, rerender } = render(<AnsiOutput text={text} query={query} currentMatch={0} agent="hermes" />);
const pre = container.querySelector("pre")!;
// `span.overflow-hidden`, not `span.inline-block`: the table-run scroller is an inline-block too.
const clipped = pre.querySelector("span.overflow-hidden")!;
Expand Down Expand Up @@ -401,7 +401,7 @@ describe("mirror line wrapping", () => {
selection.removeAllRanges();
expect(pre.textContent).toBe(visibleText);

rerender(<AnsiOutput text={text} query={query} currentMatch={0} agent="pi" wrap={false} />);
rerender(<AnsiOutput text={text} query={query} currentMatch={0} agent="hermes" wrap={false} />);
expect(container.querySelector("span.overflow-hidden")).toBeNull();
expect(container.querySelector("pre")!.textContent).toBe(visibleText);
});
Expand Down
Loading
Loading