Official

chat

Built-in chat UI (JavaScript UI plugin).

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/chat@0.2.0

Permissions in 0.2.0

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

Files

composer-scheduler.test.ts17.8 KB
// Composer intent, structured capture, failure retention and truthful inline UI.
import assert from "node:assert/strict";
import test from "node:test";
import * as Effect from "effect/Effect";
import { InputLedger, held, settledRun, withController } from "./testing.ts";
import { onSubmit, pickerKey, send, queuePrompt } from "./composer.ts";
import { nodes, chat } from "./ui-for-tests.test.ts";
import { renderPending, renderLimit } from "./views/pending.ts";
import { renderSettings } from "./settings.ts";
import { autoSend, policyOf } from "./scheduler-policy.ts";
import type { ChatView } from "./controller.ts";
import { HostCallFailed } from "convergence/effect";
import { unknownMethod } from "./scheduler.ts";

const image = { id: "image", name: "diagram.png", bytes: 100, mimeType: "image/png", width: 20, height: 20 };
test("secondary Enter and adjacent Queue hold structured image-only or mixed drafts while idle", async () => {
  const ledger = new InputLedger();
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.composer("c1").attachments = [image];
      yield* onSubmit(c, "c1", { value: "", secondary: true, shift: false });
      assert.equal(ledger.deliveries.length, 0);
      assert.deepEqual(ledger.snapshots.c1.inputs[0].blocks, [
        { type: "image", mimeType: "image/png", data: "persisted-image-bytes" },
      ]);
      assert.equal(c.composer("c1").attachments.length, 0);
      c.composer("c1").text = "Read @src/main.ts";
      c.composer("c1").attachments = [image];
      yield* queuePrompt(c, "c1");
      assert.deepEqual(
        ledger.snapshots.c1.inputs[1].blocks.map((block) => block.type),
        ["text", "file_ref", "image"],
      );
      assert.equal(ledger.deliveries.length, 0);
    }),
  );
});

test("idle Queue after a prior success does not reuse that old release boundary", async () => {
  const ledger = new InputLedger();
  ledger.settled("c1", settledRun("old-success"));
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.composer("c1").text = "Hold, do not start";
      yield* queuePrompt(c, "c1");
      yield* c.scheduler.evaluate();
      assert.equal(ledger.deliveries.length, 0);
      assert.equal(ledger.snapshots.c1.inputs[0].state, "held");
    }),
  );
});

test("plain Enter accepts the selected picker, secondary bypasses it, and Shift never submits", async () => {
  const ledger = new InputLedger();
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const composer = c.composer("c1");
      composer.text = "Inspect @src";
      composer.picker = { kind: "files", query: "src" };
      composer.files = ["src/first.ts", "src/selected.ts"];
      pickerKey(c, "c1", "down");
      yield* onSubmit(c, "c1", { value: composer.text, secondary: false, shift: false });
      assert.equal(composer.text, "Inspect @src/selected.ts ");
      assert.equal(ledger.snapshots.c1.inputs.length, 0);
      composer.text = "Inspect current draft";
      composer.picker = { kind: "files", query: "src", index: 1 };
      yield* onSubmit(c, "c1", { value: composer.text, secondary: true, shift: false });
      assert.deepEqual(ledger.snapshots.c1.inputs[0].blocks, [{ type: "text", text: "Inspect current draft" }]);
      composer.text = "line\nnext";
      yield* onSubmit(c, "c1", { value: composer.text, secondary: false, shift: true });
      assert.equal(composer.text, "line\nnext");
      assert.equal(ledger.snapshots.c1.inputs.length, 1);
    }),
  );
});

test("secondary Enter does not accept an incomplete slash command candidate", async () => {
  const ledger = new InputLedger();
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const composer = c.composer("c1");
      composer.text = "/comp";
      composer.picker = { kind: "commands", query: "comp" };
      composer.commands = [{ name: "compact", description: "Compact" }];
      yield* onSubmit(c, "c1", { value: composer.text, secondary: true, shift: false });
      assert.equal(composer.text, "/comp");
      assert.equal(ledger.snapshots.c1.inputs.length, 0);
      assert.match(ledger.notices[0], /complete command/);
    }),
  );
});

test("active Enter steers supported agents, simulated copy explains interruption, unsupported Send never queues silently", async () => {
  for (const capability of ["native", "simulated", "unsupported"]) {
    const ledger = new InputLedger();
    ledger.mutate("c1", (snapshot) => {
      snapshot.running = true;
      snapshot.runId = "active";
    });
    await withController(ledger, (c) =>
      Effect.gen(function* () {
        c.state.chats.c1 = chat({ status: "waiting_approval" });
        c.state.agents.agent.capabilities =
          capability === "native" ? { steer: true } : capability === "simulated" ? { simulatedSteer: true } : {};
        c.composer("c1").text = "Do not touch beta";
        yield* onSubmit(c, "c1", { value: "Do not touch beta", secondary: false, shift: false });
        if (capability === "unsupported") {
          assert.equal(ledger.snapshots.c1.inputs.length, 0);
          assert.equal(c.composer("c1").text, "Do not touch beta");
          assert.match(ledger.notices[0], /does not support/);
        } else {
          assert.equal(ledger.snapshots.c1.inputs[0].intent, "steer");
          assert.equal(ledger.deliveries[0].inputId, ledger.snapshots.c1.inputs[0].id);
        }
      }),
    );
  }
});

test("attachment read and enqueue failures retain the full draft, images and a visible error; enqueue retry reuses id", async () => {
  for (const kind of ["attachment", "enqueue"]) {
    const ledger = new InputLedger();
    ledger.missingImage = kind === "attachment";
    ledger.rejectEnqueue = kind === "enqueue";
    await withController(ledger, (c) =>
      Effect.gen(function* () {
        const composer = c.composer("c1");
        composer.text = "Text plus image";
        composer.attachments = [image];
        yield* send(c, "c1");
        assert.equal(composer.text, "Text plus image");
        assert.equal(composer.attachments.length, 1);
        assert.equal(ledger.forgotten.length, 0);
        assert.equal(ledger.deliveries.length, 0);
        assert.ok(composer.error);
        const previous = composer.submission?.id;
        ledger.missingImage = false;
        ledger.rejectEnqueue = false;
        yield* send(c, "c1");
        assert.equal(ledger.snapshots.c1.inputs.length, 1);
        assert.equal(ledger.deliveries[0].blocks.length, 2);
        if (previous) assert.equal(ledger.deliveries[0].inputId, previous);
      }),
    );
  }
});

test("ambiguous provider failure leaves an unknown durable copy and no automatic replay", async () => {
  const ledger = new InputLedger();
  ledger.rejectDispatch = true;
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.composer("c1").text = "Keep this recoverable";
      c.composer("c1").attachments = [image];
      yield* send(c, "c1");
      assert.equal(c.composer("c1").text, "");
      assert.equal(ledger.snapshots.c1.inputs[0].state, "delivery_unknown");
      assert.equal(ledger.snapshots.c1.inputs[0].blocks.length, 2);
      assert.ok(c.composer("c1").error);
      assert.equal(c.scheduler.snapshots.c1.inputs[0].state, "delivery_unknown");
      for (let i = 0; i < 5; i++) yield* c.scheduler.evaluate();
      assert.equal(ledger.calls.filter((call) => call.method === "host/inputs.dispatch").length, 1);
    }),
  );
});

test("typing during durable capture preserves the later draft and its attachments", async () => {
  const ledger = new InputLedger();
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const composer = c.composer("c1");
      composer.text = "Original";
      composer.attachments = [image];
      ledger.beforeEnqueue = async () => {
        assert.equal(composer.text, "Original");
        assert.equal(composer.attachments.length, 1);
        composer.text = "Later draft";
      };
      yield* queuePrompt(c, "c1");
      assert.equal(composer.text, "Later draft");
      assert.equal(composer.attachments.length, 1);
      assert.equal(ledger.forgotten.length, 0);
      assert.deepEqual(ledger.snapshots.c1.inputs[0].blocks[0], { type: "text", text: "Original" });
    }),
  );
});

test("a deferred non-prompt command executes only on explicit dispatch and never releases FIFO", async () => {
  const ledger = new InputLedger();
  ledger.settled("c1", settledRun("previous"));
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.composer("c1").text = "/no-prompt";
      yield* queuePrompt(c, "c1");
      c.composer("c1").text = "Held follow-up";
      yield* queuePrompt(c, "c1");
      assert.equal(ledger.deliveries.length, 0);
      const input = ledger.snapshots.c1.inputs[0];
      yield* c.scheduler.promote("c1", input.id, ledger.snapshots.c1.revision);
      yield* c.scheduler.evaluate();
      assert.equal(ledger.deliveries.length, 1);
      assert.equal(ledger.snapshots.c1.running, false);
      assert.equal(ledger.snapshots.c1.inputs[1].state, "held");
    }),
  );
});

test("unknown-method compatibility is positive-only; permission and transport failures cannot fall back", () => {
  assert.equal(
    unknownMethod(new HostCallFailed({ method: "host/inputs.list", code: -32601, message: "unknown" })),
    true,
  );
  assert.equal(
    unknownMethod(new HostCallFailed({ method: "host/inputs.enqueue", code: -32603, message: "transport lost" })),
    false,
  );
  assert.equal(unknownMethod({ _tag: "PermissionNotGranted", message: "unknown host method host/inputs.list" }), false);
});

test("native admitted/unknown rows show full steer text and no edit/remove; grace and settings share agent preference", async () => {
  const ledger = new InputLedger();
  ledger.mutate("c1", (snapshot) => {
    snapshot.inputs = [
      held("unknown", {
        intent: "steer",
        state: "delivery_unknown",
        blocks: [{ type: "text", text: "Inspect alpha\nDo not change beta" }],
      }),
      held("admitted", { intent: "steer", state: "native_admitted" }),
    ];
    snapshot.running = true;
    snapshot.runId = "active";
  });
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const view: ChatView = {
        controller: c,
        ctx: { state: {}, app: { route: "chat", chatId: "c1" }, plugin: "chat", update() {}, run() {} },
      };
      const rows = renderPending(view, c.state.chats.c1).flatMap((row) => nodes(row));
      assert.ok(rows.some((node) => node.text === "Inspect alpha\nDo not change beta"));
      assert.ok(!rows.some((node) => typeof node.id === "string" && /edit-input|remove-input|promote-/.test(node.id)));
      c.scheduler.activeLimits.c1 = {
        runId: "active",
        evidence: {
          availability: "blocked",
          source: "native",
          observedAt: "2026-10-04T12:00:00Z",
          reason: "Limit",
          identity: "account",
        },
      };
      assert.ok(
        nodes(renderLimit(view, c.state.chats.c1)).some(
          (node) => node.text === "Usage limit reported · still working / retrying",
        ),
      );
      assert.ok(nodes(renderLimit(view, c.state.chats.c1)).some((node) => node.text === "Reset time unavailable"));
      yield* c.scheduler.setAutoSend("agent", false);
      assert.equal(
        nodes(renderLimit(view, c.state.chats.c1)).find((node) => node.id === "auto-limit-c1")?.checked,
        false,
      );
      assert.equal(nodes(renderSettings(view)).find((node) => node.id === "auto-limit-agent-agent")?.checked, false);
    }),
  );
});

test("quick checkbox and settings handlers update the same global preference and repaint settings", async () => {
  const ledger = new InputLedger(["c1", "c2"]);
  for (const id of ["c1", "c2"])
    ledger.mutate(id, (snapshot) => {
      snapshot.inputs = [held(`next:${id}`, { chatId: id })];
      snapshot.policyState = {
        version: 1,
        generation: 1,
        wait: { runId: "limit", stoppedAt: "2026-10-04T12:00:00Z", reason: "Limit", status: "armed" },
      };
    });
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const actions: Effect.Effect<unknown, unknown>[] = [];
      let settingsUpdates = 0;
      c.settingsView = {
        id: 10,
        update: () => {
          settingsUpdates++;
        },
        remove() {},
      };
      const view: ChatView = {
        controller: c,
        ctx: {
          state: {},
          app: { route: "chat", chatId: "c1" },
          plugin: "chat",
          update() {},
          run: (effect) => {
            // These local handlers use the controller's injected services, as
            // in ui-for-tests; none requires additional Effect environment.
            actions.push(effect as Effect.Effect<unknown, unknown>);
          },
        },
      };
      const quick = nodes(renderLimit(view, c.state.chats.c1)).find((node) => node.id === "auto-limit-c1");
      assert.ok(quick);
      // The local tree contains the boolean checkbox handler, not a text input.
      (quick.onChange as unknown as (event: { value: boolean }) => void)({ value: false });
      yield* Effect.all(actions.splice(0));
      assert.deepEqual(ledger.settings.autoSendAfterLimit, { agent: false });
      assert.equal(autoSend(c.scheduler.preferences, "agent"), false);
      assert.equal(
        nodes(renderLimit(view, c.state.chats.c2)).find((node) => node.id === "auto-limit-c2")?.checked,
        false,
      );
      assert.equal(nodes(renderSettings(view)).find((node) => node.id === "auto-limit-agent-agent")?.checked, false);
      assert.ok(settingsUpdates > 0, "retained settings view must repaint after preference application");
      const setting = nodes(renderSettings(view)).find((node) => node.id === "auto-limit-agent-agent");
      assert.ok(setting);
      (setting.onChange as unknown as (event: { value: boolean }) => void)({ value: true });
      yield* Effect.all(actions.splice(0));
      assert.deepEqual(ledger.settings.autoSendAfterLimit, { agent: true });
      assert.equal(
        nodes(renderLimit(view, c.state.chats.c1)).find((node) => node.id === "auto-limit-c1")?.checked,
        true,
      );
      assert.equal(ledger.deliveries.length, 0, "preferences alone cannot establish missing identity/reset evidence");
    }),
  );
});

test("held edit and explicit promotion handlers preserve every attachment and an unrelated draft", async () => {
  const ledger = new InputLedger();
  const attachments = [
    { type: "image", mimeType: "image/png", data: "image-bytes" },
    { type: "audio", mimeType: "audio/wav", data: "audio-bytes" },
    { type: "file_ref", path: "src/main.ts" },
    { type: "resource", path: "notes.md", text: "resource contents" },
    { type: "skill", name: "review", input: "inspect changes" },
  ] satisfies import("convergence").ContentBlock[];
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      const composer = c.composer("c1");
      composer.text = "Unrelated draft";
      composer.attachments = [image];
      yield* c.scheduler.enqueue("c1", "editable", [{ type: "text", text: "Original" }, ...attachments], "queue");
      const actions: Effect.Effect<unknown, unknown>[] = [];
      const view: ChatView = {
        controller: c,
        ctx: {
          state: {},
          app: { route: "chat", chatId: "c1" },
          plugin: "chat",
          update() {},
          run: (effect) => {
            // Held edit/promote call only the injected controller services.
            actions.push(effect as Effect.Effect<unknown, unknown>);
          },
        },
      };
      const rows = () => nodes(renderPending(view, c.state.chats.c1));
      rows()
        .find((node) => node.id === "edit-input-editable")
        ?.onClick();
      assert.ok(composer.pendingEdit);
      rows()
        .find((node) => node.id === "edit-pending-editable")
        ?.onChange({ value: "Edited held input" });
      rows()
        .find((node) => node.id === "save-pending-editable")
        ?.onClick();
      yield* Effect.all(actions.splice(0));
      assert.deepEqual(ledger.snapshots.c1.inputs[0].blocks, [
        { type: "text", text: "Edited held input" },
        ...attachments,
      ]);
      rows()
        .find((node) => node.id === "promote-editable")
        ?.onClick();
      yield* Effect.all(actions.splice(0));
      assert.equal(ledger.deliveries[0].inputId, "editable");
      assert.deepEqual(ledger.deliveries[0].blocks, [{ type: "text", text: "Edited held input" }, ...attachments]);
      assert.equal(composer.text, "Unrelated draft");
      assert.deepEqual(composer.attachments, [image]);
      assert.deepEqual(ledger.forgotten, []);
    }),
  );
});

test("user enqueue wins if generated Continue reaches the host after the user's input", async (ctx) => {
  ctx.mock.method(Date, "now", () => Date.parse("2026-10-04T12:00:00Z"));
  const ledger = new InputLedger();
  ledger.settings.generateContinue = true;
  ledger.settled(
    "c1",
    settledRun("limit", {
      outcome: { status: "failed", message: "Limit" },
      usageLimit: {
        availability: "blocked",
        source: "native",
        observedAt: "2026-10-04T12:00:00Z",
        reason: "Limit",
        identity: "account",
        resetsAt: "2026-10-04T12:05:00Z",
      },
    }),
  );
  // An initialized previous policy lets this new settled interruption be handled.
  ledger.mutate("c1", (snapshot) => {
    snapshot.policyState = { version: 1, generation: 1 };
  });
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      ledger.beforeEnqueue = async () => {
        ledger.beforeEnqueue = undefined;
        await Effect.runPromise(
          c.scheduler.enqueue("c1", "user-wins", [{ type: "text", text: "User instruction" }], "queue"),
        );
      };
      yield* c.scheduler.updatePreferences();
      yield* c.scheduler.evaluate();
      assert.deepEqual(
        ledger.snapshots.c1.inputs.map((input) => input.id),
        ["user-wins"],
      );
      assert.equal(policyOf(ledger.snapshots.c1).wait?.generated, true);
      assert.equal(ledger.deliveries.length, 0);
    }),
  );
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.