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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.