97852f43b8
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
108 lines
4.8 KiB
TypeScript
108 lines
4.8 KiB
TypeScript
/**
|
|
* midRunAction: what the composer's single action button does while a Task is running.
|
|
*
|
|
* The case this file exists for is the last describe below — a draft the mid-run channels
|
|
* refuse must still leave Stop. That failed twice in a row when the rule lived inline in
|
|
* ChatInput and was written as "Stop when the composer is empty": an unsendable draft is not
|
|
* empty, so Stop vanished behind a Send that could never enable, and the only way to abort was
|
|
* to delete what you had typed.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { midRunAction } from "../src/features/chat/composer-send";
|
|
import type { MidRunComposerState } from "../src/features/chat/composer-send";
|
|
|
|
/** An active session with both channels wired, steer mode, and a one-line text draft. */
|
|
const BASE: MidRunComposerState = {
|
|
sending: false,
|
|
goalOn: false,
|
|
modelAuthDead: false,
|
|
canSteerChannel: true,
|
|
canQueueChannel: true,
|
|
followUpMode: false,
|
|
stagedRoute: "post",
|
|
hasHandoffTarget: false,
|
|
hasPendingModel: false,
|
|
hasText: true,
|
|
hasImages: false,
|
|
hasContent: true,
|
|
};
|
|
|
|
const act = (over: Partial<MidRunComposerState> = {}) => midRunAction({ ...BASE, ...over });
|
|
|
|
/** The draft shapes that carry nothing at all. */
|
|
const EMPTY = { hasText: false, hasImages: false, hasContent: false } as const;
|
|
|
|
describe("midRunAction — steering, the preferred channel", () => {
|
|
it("takes text, images, or an image with no caption at all", () => {
|
|
expect(act()).toBe("steer");
|
|
expect(act({ hasText: false, hasImages: true })).toBe("steer");
|
|
expect(act({ hasText: true, hasImages: true })).toBe("steer");
|
|
});
|
|
|
|
it("is skipped for a draft it cannot carry, which the queue then takes", () => {
|
|
// Skills or file attachments only: hasContent without text or images of its own.
|
|
expect(act({ ...EMPTY, hasContent: true })).toBe("queue");
|
|
// A staged switch chip: the text belongs to the conversation that switch is about to open.
|
|
expect(act({ hasHandoffTarget: true, stagedRoute: "handoff" })).toBe("queue");
|
|
expect(act({ hasPendingModel: true, stagedRoute: "model" })).toBe("queue");
|
|
});
|
|
|
|
it("is skipped in follow-up mode even when it could carry the draft", () => {
|
|
// The two channels put the message in different places; the remembered mode decides.
|
|
expect(act({ followUpMode: true })).toBe("queue");
|
|
});
|
|
});
|
|
|
|
describe("midRunAction — the queue", () => {
|
|
it("takes the whole draft, whatever it is made of", () => {
|
|
expect(act({ followUpMode: true, ...EMPTY, hasContent: true })).toBe("queue");
|
|
});
|
|
|
|
it("refuses a staged /model fork while the Session is still writing its Trace", () => {
|
|
// stagedRoute "blocked" is the fork waiting for idle (see stagedSendRoute); steering is
|
|
// out too, since a staged chip rules it out — so the button is Stop, not a dead Send.
|
|
expect(act({ hasPendingModel: true, stagedRoute: "blocked" })).toBe("stop");
|
|
expect(act({ followUpMode: true, hasPendingModel: true, stagedRoute: "blocked" })).toBe("stop");
|
|
});
|
|
|
|
it("is unavailable on a host that wired no queue (the draft page)", () => {
|
|
expect(act({ canQueueChannel: false, ...EMPTY, hasContent: true })).toBe("stop");
|
|
// Steering still works there if the draft suits it.
|
|
expect(act({ canQueueChannel: false })).toBe("steer");
|
|
});
|
|
});
|
|
|
|
describe("midRunAction — Stop is the fallthrough, not a case", () => {
|
|
it("an empty composer is Stop", () => {
|
|
expect(act(EMPTY)).toBe("stop");
|
|
});
|
|
|
|
// The regression this file is really for: each of these is a NON-EMPTY draft that no mid-run
|
|
// channel will take. Keyed off "is the composer empty" they produced a permanently disabled
|
|
// Send standing where Stop should be, so the run could not be stopped from the composer.
|
|
it("a goal objective leaves Stop — it is an objective, not a message for this turn", () => {
|
|
expect(act({ goalOn: true })).toBe("stop");
|
|
expect(act({ goalOn: true, followUpMode: true })).toBe("stop");
|
|
expect(act({ goalOn: true, hasImages: true })).toBe("stop");
|
|
});
|
|
|
|
it("a rejected model key leaves Stop, for every draft there is", () => {
|
|
expect(act({ modelAuthDead: true })).toBe("stop");
|
|
expect(act({ modelAuthDead: true, followUpMode: true })).toBe("stop");
|
|
expect(act({ modelAuthDead: true, ...EMPTY, hasContent: true })).toBe("stop");
|
|
});
|
|
|
|
it("a host with no channels at all leaves Stop rather than a dead Send", () => {
|
|
expect(act({ canSteerChannel: false, canQueueChannel: false })).toBe("stop");
|
|
});
|
|
});
|
|
|
|
describe("midRunAction — a send already in flight", () => {
|
|
it("is inert, and never Stop: the click that started the send must not abort the run", () => {
|
|
expect(act({ sending: true })).toBe("disabled");
|
|
// Including once the send has cleared the draft but has not settled yet.
|
|
expect(act({ sending: true, ...EMPTY })).toBe("disabled");
|
|
expect(act({ sending: true, goalOn: true })).toBe("disabled");
|
|
});
|
|
});
|