evOo-local/packages/core/src/action-queue.test.ts
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

337 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
ActionQueue,
assertStepTransition,
canTransitionStep,
DynamicActionQueueImpl,
isActiveStepStatus,
isTerminalStepStatus,
} from "./action-queue.js";
describe("Outcome-Driven Action Queue State Machine (packages/core/src/action-queue.ts)", () => {
describe("Tier 1: Feature Coverage (Core Lifecycle & Interface Contracts)", () => {
it("T1.1: initializes with empty steps and activeStepId === null", () => {
const q = new DynamicActionQueueImpl();
expect(q.steps).toEqual([]);
expect(q.activeStepId).toBeNull();
expect(q.getActiveStep()).toBeNull();
expect(q.isComplete()).toBe(false);
expect(q.hasFailed()).toBe(false);
});
it("T1.2: synthesizeInput decomposes multi-part instructions into sequential steps with criteria", () => {
const q = new DynamicActionQueueImpl();
const created = q.synthesizeInput(
"Install dependencies. Run test suite. Build production bundle.",
);
expect(created.length).toBe(3);
expect(q.steps.length).toBe(3);
expect(q.steps[0]!.id).toBe("step-1");
expect(q.steps[0]!.intent).toBe("Install dependencies");
expect(q.steps[0]!.outcomeCriteria).toBe(
"Verify Install dependencies completed successfully",
);
expect(q.steps[0]!.status).toBe("pending");
expect(q.steps[1]!.intent).toBe("Run test suite");
expect(q.steps[2]!.intent).toBe("Build production bundle");
expect(q.steps.every((s) => s.status === "pending")).toBe(true);
});
it("T1.3: advance transitions first pending step to running with startedAt timestamp", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Run audit then deploy");
const active = q.advance();
expect(active).not.toBeNull();
expect(active!.id).toBe("step-1");
expect(q.activeStepId).toBe("step-1");
expect(q.steps[0]!.status).toBe("running");
expect(q.steps[0]!.startedAt).toBeGreaterThan(0);
expect(q.steps[1]!.status).toBe("pending");
expect(q.steps[1]!.startedAt).toBeUndefined();
});
it("T1.4: sequential advance completes active step with completedAt and starts next step", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Step A; Step B");
q.advance(); // Starts step A
expect(q.steps[0]!.status).toBe("running");
q.advance(); // Completes step A, starts step B
expect(q.steps[0]!.status).toBe("completed");
expect(q.steps[0]!.completedAt).toBeGreaterThan(0);
expect(q.steps[1]!.status).toBe("running");
expect(q.steps[1]!.startedAt).toBeGreaterThan(0);
expect(q.activeStepId).toBe(q.steps[1]!.id);
q.advance(); // Completes step B
expect(q.steps[1]!.status).toBe("completed");
expect(q.steps[1]!.completedAt).toBeGreaterThan(0);
expect(q.activeStepId).toBeNull();
expect(q.isComplete()).toBe(true);
});
it("T1.5: cancelActive marks active running step cancelled with timestamp and clears activeStepId", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Long running command; Step 2");
q.advance();
expect(q.steps[0]!.status).toBe("running");
const cancelled = q.cancelActive("user_barge_in");
expect(cancelled).not.toBeNull();
expect(cancelled!.status).toBe("cancelled");
expect(cancelled!.completedAt).toBeGreaterThan(0);
expect(q.activeStepId).toBeNull();
expect(q.steps[0]!.status).toBe("cancelled");
expect(q.steps[1]!.status).toBe("pending");
});
it("T1.6: updateStepStatus transitions step status and syncs activeStepId and timestamps", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Step 1");
q.updateStepStatus("step-1", "running");
expect(q.steps[0]!.status).toBe("running");
expect(q.activeStepId).toBe("step-1");
expect(q.steps[0]!.startedAt).toBeGreaterThan(0);
q.updateStepStatus("step-1", "completed");
expect(q.steps[0]!.status).toBe("completed");
expect(q.activeStepId).toBeNull();
expect(q.steps[0]!.completedAt).toBeGreaterThan(0);
});
});
describe("Tier 2: Boundary & Corner Cases", () => {
it("T2.1: synthesizeInput with empty or whitespace-only string is clean no-op", () => {
const q = new DynamicActionQueueImpl();
expect(q.synthesizeInput("")).toEqual([]);
expect(q.synthesizeInput(" \n\t ")).toEqual([]);
expect(q.steps.length).toBe(0);
});
it("T2.2: advance on empty or fully completed queue is a safe no-op", () => {
const q = new DynamicActionQueueImpl();
expect(() => q.advance()).not.toThrow();
expect(q.advance()).toBeNull();
expect(q.activeStepId).toBeNull();
q.synthesizeInput("Single step");
q.advance(); // running
q.advance(); // completed
expect(q.advance()).toBeNull();
expect(q.activeStepId).toBeNull();
});
it("T2.3: cancelActive when no step is active is a safe no-op", () => {
const q = new DynamicActionQueueImpl();
expect(q.cancelActive("no_active")).toBeNull();
expect(q.activeStepId).toBeNull();
q.synthesizeInput("Pending step");
expect(q.cancelActive("no_active")).toBeNull();
expect(q.activeStepId).toBeNull();
});
it("T2.4: failed step halts automatic advance until retry or cancellation", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Failing step; Subsequent step");
q.advance();
q.updateStepStatus("step-1", "failed");
expect(q.hasFailed()).toBe(true);
const advanced = q.advance();
expect(advanced).toBeNull();
expect(q.activeStepId).toBeNull();
expect(q.steps[1]!.status).toBe("pending");
});
it("T2.5: state transition validator strictly enforces allowed lifecycle transitions", () => {
expect(canTransitionStep("pending", "running")).toBe(true);
expect(canTransitionStep("pending", "cancelled")).toBe(true);
expect(canTransitionStep("pending", "completed")).toBe(false);
expect(canTransitionStep("pending", "failed")).toBe(false);
expect(canTransitionStep("running", "completed")).toBe(true);
expect(canTransitionStep("running", "failed")).toBe(true);
expect(canTransitionStep("running", "cancelled")).toBe(true);
expect(canTransitionStep("running", "pending")).toBe(false);
expect(canTransitionStep("completed", "running")).toBe(false);
expect(canTransitionStep("cancelled", "completed")).toBe(false);
expect(canTransitionStep("failed", "running")).toBe(true); // retry allowed
expect(canTransitionStep("failed", "cancelled")).toBe(true); // abandon allowed
expect(() => assertStepTransition("completed", "running")).toThrow(
"Illegal step transition from completed to running",
);
});
it("T2.6: updateStepStatus throws error when step does not exist", () => {
const q = new DynamicActionQueueImpl();
expect(() => q.updateStepStatus("nonexistent", "running")).toThrow("Step not found");
});
});
describe("Tier 3: Dynamic Mutation & Reordering", () => {
it("T3.1: reorderSteps repositions steps by index", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("A; B; C");
q.reorderSteps(0, 2);
expect(q.steps.map((s) => s.intent)).toEqual(["B", "C", "A"]);
});
it("T3.2: reorderSteps throws RangeError on invalid index bounds", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("A; B");
expect(() => q.reorderSteps(-1, 1)).toThrow(RangeError);
expect(() => q.reorderSteps(0, 5)).toThrow(RangeError);
});
it("T3.3: insertStep adds a new step at specific index or end", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("First; Third");
const inserted = q.insertStep({ intent: "Second" }, 1);
expect(inserted.intent).toBe("Second");
expect(inserted.id).toBe("step-3");
expect(q.steps.map((s) => s.intent)).toEqual(["First", "Second", "Third"]);
});
it("T3.4: removeStep removes a step from the queue and clears activeStepId if active", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("A; B");
q.advance(); // step-1 running
expect(q.activeStepId).toBe("step-1");
const removedActive = q.removeStep("step-1");
expect(removedActive).toBe(true);
expect(q.activeStepId).toBeNull();
expect(q.steps.length).toBe(1);
const removedNonExistent = q.removeStep("step-99");
expect(removedNonExistent).toBe(false);
});
it("T3.5: retryStep resets failed step to pending and clears completedAt timestamp", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Failed command");
q.advance();
q.updateStepStatus("step-1", "failed");
const retried = q.retryStep("step-1");
expect(retried.status).toBe("pending");
expect(retried.completedAt).toBeUndefined();
expect(q.hasFailed()).toBe(false);
// Can now advance
q.advance();
expect(q.steps[0]!.status).toBe("running");
});
it("T3.6: retryStep throws when called on non-failed step", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Pending command");
expect(() => q.retryStep("step-1")).toThrow(
'Cannot retry step with status "pending"; only failed steps can be retried',
);
});
});
describe("Tier 4: Serialization & Helpers", () => {
it("T4.1: toJSON and fromJSON preserve complete state and timestamp fidelity", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Inspect; Test; Deploy");
q.advance(); // step-1 running
const snapshot = q.toJSON();
const restored = DynamicActionQueueImpl.fromJSON(snapshot);
expect(restored.steps.length).toBe(3);
expect(restored.activeStepId).toBe("step-1");
expect(restored.steps[0]!.status).toBe("running");
expect(restored.steps[0]!.startedAt).toBe(q.steps[0]!.startedAt);
expect(restored.steps[1]!.status).toBe("pending");
});
it("T4.2: native JSON.stringify and JSON.parse round-trip preserves state", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Analyze; Build");
q.advance();
const jsonStr = JSON.stringify(q);
const parsed = JSON.parse(jsonStr);
expect(parsed.steps.length).toBe(2);
expect(parsed.activeStepId).toBe("step-1");
});
it("T4.3: helper queries (getPendingSteps, getStep, isActiveStepStatus, isTerminalStepStatus)", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("S1; S2; S3");
expect(q.getPendingSteps().length).toBe(3);
q.advance();
expect(q.getPendingSteps().length).toBe(2);
expect(q.getStep("step-1")?.status).toBe("running");
expect(isActiveStepStatus(q.getStep("step-1")!.status)).toBe(true);
q.cancelActive();
expect(isTerminalStepStatus(q.getStep("step-1")!.status)).toBe(true);
});
it("T4.4: ActionQueue alias points to DynamicActionQueueImpl", () => {
expect(ActionQueue).toBe(DynamicActionQueueImpl);
const q = new ActionQueue();
expect(q).toBeInstanceOf(DynamicActionQueueImpl);
});
});
describe("Tier 5: Real-World Scenario Hardening & Adversarial Stress", () => {
it("T5.1: complex speech transcript with conjunctions decomposes cleanly", () => {
const transcript =
"First review the pull request, then run security scan, and build docker image";
const q = new DynamicActionQueueImpl();
q.synthesizeInput(transcript);
expect(q.steps.length).toBe(3);
expect(q.steps[0]!.intent).toContain("First review the pull request");
expect(q.steps[1]!.intent).toContain("run security scan");
expect(q.steps[2]!.intent).toContain("build docker image");
});
it("T5.2: multiple sequential syntheses append monotonically numbered steps", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Task 1; Task 2");
expect(q.steps.map((s) => s.id)).toEqual(["step-1", "step-2"]);
q.synthesizeInput("Task 3; Task 4");
expect(q.steps.map((s) => s.id)).toEqual(["step-1", "step-2", "step-3", "step-4"]);
});
it("T5.3: custom outcome criteria template applies to all synthesized steps", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("Lint files; Run vitest", {
criteriaTemplate: (intent) => `Ensure 0 errors for ${intent}`,
});
expect(q.steps[0]!.outcomeCriteria).toBe("Ensure 0 errors for Lint files");
expect(q.steps[1]!.outcomeCriteria).toBe("Ensure 0 errors for Run vitest");
});
it("T5.4: clear resets queue completely", () => {
const q = new DynamicActionQueueImpl();
q.synthesizeInput("A; B; C");
q.advance();
expect(q.steps.length).toBe(3);
expect(q.activeStepId).not.toBeNull();
q.clear();
expect(q.steps.length).toBe(0);
expect(q.activeStepId).toBeNull();
});
});
});