Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ product overview.

## Unreleased

- Show enabled optional providers in the admin channel Runtime picker and preserve their saved
engine/model selection when changing unrelated channel settings.

- Clear typed tokens when Slack Settings reopens a form after an asynchronous save refusal,
including a custom MCP address rejected by DNS validation. Non-secret fields remain filled;
leaving an existing connection's token blank still keeps its saved token.
Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@

## Provider selection readiness

- The admin channel Runtime picker lists enabled engine adapters, including optional providers,
and retains their saved engine/model selection when an unrelated channel setting is saved.
- Enabled optional providers must have their own configured credential and endpoint before a
runtime selection is saved in `/model`, Slack Settings, admin default/channel/DM saves, model
shortcuts or explicit provider directives. Rejection explains the required Settings repair and
Expand Down
6 changes: 6 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,12 @@ must run applicable gates before stable promotion; no live pass is implied by au

## Provider selection readiness (2026-10-08)

- [x] Browser regression: `CG_BROWSER_MODULE=<playwright-module> node --test
test/channel-engine-picker-browser.test.js` exercises the real channel editor and admin API.
Both configured optional providers appear alongside Default/Claude/Codex; disabled adapters
are omitted. Existing engine/model pins survive unrelated saves and reloads, and another
enabled provider can be selected and saved. This is isolated regression evidence; the live
provider/client acceptance below remains separate.
- [x] Regression: `node --test test/provider-selection-surfaces.test.js
test/qwen-provider-settings-route.test.js test/model-wizard-channel-scope-thread.test.js
test/channel-settings-modal.test.js test/platform-controls.test.js test/qwen-engine.test.js`.
Expand Down
1 change: 1 addition & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,7 @@ function renderChannelDetail(ch) {
approvedBox.textContent = "";
}
const engineSelect = card.querySelector(".ch-engine");
engineSelect.innerHTML = engineOptionsHtml({ includeDefault: true });
engineSelect.value = meta.engine || "";
const authSource = card.querySelector(".ch-codex-auth-source");
const channelPanel = card.querySelector(".ch-auth-channel-panel");
Expand Down
80 changes: 80 additions & 0 deletions test/channel-engine-picker-browser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ensureTestEnv } from "./helpers.js";

ensureTestEnv();

test("channel runtime picker offers enabled providers and preserves their pins on unrelated saves", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
const { chromium } = await import(process.env.CG_BROWSER_MODULE);
const { createAdminRouter } = await import("../src/web/routes/admin.js");
const { saveSettings } = await import("../src/config/settings.js");
const { QWEN_PROVIDERS } = await import("../src/engines/qwen.js");
const { upsertChannelEntry, saveChannelMeta, getChannelMeta, defaultChannelMeta } = await import("../src/config/store.js");
saveSettings({
engineEnabled: { claude: true, codex: true, qwen: true, "qwen-eu": true, opencode: false },
...Object.fromEntries(QWEN_PROVIDERS.flatMap((provider) => [
[provider.settings.apiKey, `qa-picker-${provider.id}-dummy-key`],
[provider.settings.baseUrl, `https://${provider.id}.example/anthropic`],
])),
});
const channels = [];
for (const [index, provider] of QWEN_PROVIDERS.entries()) {
const channelId = `C_PROVIDER_PICKER_${index}`;
const entry = await upsertChannelEntry(channelId, { name: `provider-picker-${index}`, type: "channel", isDM: false });
await saveChannelMeta(entry.slug, {
...defaultChannelMeta({ channelId, name: entry.name, type: "channel", isDM: false }),
engine: provider.id, model: provider.models[0].value, allowNetwork: false,
});
channels.push({ channelId, entry, provider });
}
const app = express();
app.use(express.json());
app.get("/api/mcp/available", (_req, res) => res.json({ servers: [] }));
app.get("/api/health", (_req, res) => res.json({ slack: { connected: false, status: "off" }, engines: {} }));
app.use("/api", createAdminRouter({ slack: { getClient: () => null, snapshot: () => ({ status: "disconnected", connected: false }) } }));
const publicDir = fileURLToPath(new URL("../public", import.meta.url));
app.use(express.static(publicDir, { dotfiles: "allow" }));
app.get("/conversations/channel/:id", (_req, res) => res.sendFile(path.join(publicDir, "index.html"), { dotfiles: "allow" }));
const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }));
const browser = await chromium.launch({ headless: true });
t.after(() => browser.close());
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
for (const { channelId, entry, provider } of channels) {
await page.goto(`http://127.0.0.1:${server.address().port}/conversations/channel/${channelId}`);
await page.locator('[data-pane="runtime"].subtab').click();
const engine = page.locator(".ch-engine");
const offered = await engine.locator("option").evaluateAll((options) => options.map((option) => option.value));
for (const id of ["", "claude", "codex", ...QWEN_PROVIDERS.map((p) => p.id)]) assert.ok(offered.includes(id), `enabled ${id || "default"} is offered`);
assert.ok(!offered.includes("opencode"), "disabled harness stays out of the picker");
assert.equal(await engine.inputValue(), provider.id, "the existing provider remains selected");
assert.equal(await page.locator(".ch-model").inputValue(), provider.models[0].value);
await page.locator('[data-pane="access"].subtab').click();
await page.locator(".ch-network-wrap").click();
assert.equal(await page.locator(".ch-network").isChecked(), true);
const saved = page.waitForResponse((response) => response.url().endsWith(`/api/channels/${channelId}/meta`) && response.request().method() === "PUT");
await page.locator(".save-channel").click();
const response = await saved;
assert.equal(response.status(), 200);
const body = response.request().postDataJSON();
assert.equal(body.engine, provider.id, "unrelated save sends the original engine");
assert.equal((await getChannelMeta(entry.slug)).engine, provider.id);
assert.equal((await getChannelMeta(entry.slug)).model, provider.models[0].value);
await page.reload();
await page.locator('[data-pane="runtime"].subtab').click();
assert.equal(await engine.inputValue(), provider.id, "the provider survives reload");
const next = QWEN_PROVIDERS.find((p) => p.id !== provider.id);
await engine.selectOption(next.id);
await page.locator(".ch-model").selectOption(next.models[0].value);
const switched = page.waitForResponse((r) => r.url().endsWith(`/api/channels/${channelId}/meta`) && r.request().method() === "PUT");
await page.locator(".save-channel").click();
assert.equal((await switched).status(), 200);
assert.equal((await getChannelMeta(entry.slug)).engine, next.id, "another enabled provider can be selected and saved");
}
assert.deepEqual(errors, []);
});
Loading