Files
learnharness/tests/render-composer.test.tsx
T
amosblomqvist 5271ca0e4a composer steering: the general steering machinery (owner arc 2026-10-03)
STEERING, NOT FOLLOW-UPS — pi-agent-core's two rails, used deliberately:
agent.steer() injects at the next response boundary INSIDE the live run
(one logical turn, the model changes course); followUp() would only extend
an ended run — not built. The same rail job markers already ride
(steeringMode 'all', the 2026-09-26 doctrine).

- POST /api/chats/[id]/steer: 404/400 honest guards; no live turn → 409
  and the client silently falls back to a plain text send (the settle
  race); live → steerLiveChat hands the message to the live agent's
  steering queue AND parks it in the userSteers registry — the stranding
  safety net. A steer stranded behind a question termination or abort
  stays parked and rides the chat's NEXT run (handOffEligible), the same
  deliver-or-requeue lifecycle as job markers. Reconciliation at settle:
  delivered ids drop (reconcileUserSteers).
- Client: steer(text) queues + POSTs without flipping any turn state;
  the drain's verbatim message events upsert the real user card by the
  client-minted id (steer_<localid>) and the indicator fold drops the
  line. canSend no longer gates on turnInFlight — Enter mid-turn steers;
  Enter with an empty composer still stops; the square button stays Stop.
- The queued-steer indicator (owner spec): quiet one-liners above the
  composer shell — right-aligned to where its corner rounding starts,
  truncated with an ellipsis, fixed max width, ~half opacity, no
  background, no container. Tunable later.
- Verified from pi-agent-core source: steering drains per completed turn
  inside the loop; injected messages emit message_start/end verbatim;
  finishTurn {action:'end'} (our question stopper) exits BEFORE the
  steering poll — hence the registry.
2026-10-03 03:01:51 +03:00

80 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Composer render tests — the chat input row (content only; the persistent
* rounded shell lives in the chat view's overlay so it can morph into the
* question dock without remounting).
*
* Same tsx classic-JSX shim as render-cards.test.tsx.
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Composer, enterAction } from "../components/composer.tsx";
function render(opts: { busy?: boolean; canSend?: boolean; placeholder?: string } = {}) {
return renderToStaticMarkup(
<Composer
busy={opts.busy ?? false}
value=""
onChange={() => {}}
onSend={() => {}}
onStop={() => {}}
canSend={opts.canSend ?? true}
{...(opts.placeholder ? { placeholder: opts.placeholder } : {})}
/>,
);
}
describe("Composer", () => {
it("chat placeholder + send button", () => {
const html = render();
assert.match(html, /Ask, answer, or steer the lesson/, "the default — active sessions");
assert.ok(html.includes('aria-label="Send"'), "send button present");
});
it("placeholder is overridable — the new-session page asks the graph view's question", () => {
const html = render({ placeholder: "What do you want to learn?" });
assert.match(html, /What do you want to learn\?/);
assert.ok(!html.includes("Ask, answer"), "the session placeholder must not ride along");
});
it("busy: send button becomes stop", () => {
const html = render({ busy: true });
assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming");
});
it("send button radius is 10px = 25% of the 40px button (concentric with the shell)", () => {
const html = render();
assert.ok(html.includes("rounded-[10px]"), "send radius = shell radius − row padding");
assert.ok(html.includes("size-10"), "40px send button (10px = 25%)");
});
it("disabled when nothing to send", () => {
const html = render({ canSend: false });
assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled");
});
});
// The Enter decision (composer steering, 2026-10-03): while a turn runs,
// typed text makes Enter a STEER; Enter with an empty composer stays the
// stop shortcut. (The plan-verdict accept branch died 2026-09-26 with the
// verdict itself — the post-plan reply is a natural text message.) Pure
// function so the static-render harness can pin it (key events can't be
// simulated in renderToStaticMarkup).
describe("enterAction", () => {
it("busy stream with text: Enter steers (the queued send)", () => {
assert.equal(enterAction({ busy: true, canSend: true }), "send");
});
it("busy stream, empty composer: Enter stops", () => {
assert.equal(enterAction({ busy: true, canSend: false }), "stop");
});
it("idle: send when there is text, nothing when there is not", () => {
assert.equal(enterAction({ busy: false, canSend: true }), "send");
assert.equal(enterAction({ busy: false, canSend: false }), "none");
});
});