From 0e52e68a32d8c6792865cfa9dc77d8a7a1a424f2 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 15 Oct 2026 22:05:31 +0300 Subject: [PATCH] Show the active model in native Slack agent sessions Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 +++ FEATURES.md | 11 +++++-- TEST-PLAN.md | 13 ++++++++ slack-app-manifest.json | 1 + src/slack/agent-sessions.js | 36 +++++++++++++++++++-- src/slack/progress.js | 18 ++++++----- test/channelgate-rename.test.js | 2 +- test/slack-agent-sessions.test.js | 52 +++++++++++++++++++++++++++++++ test/slack-progress.test.js | 40 +++++++++++++++++++++--- 9 files changed, 159 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0796071..596e126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ product overview. ## Unreleased +- Show the active model in Slack's native Stop control using a per-session display name. + Existing apps need `chat:write.customize` and reinstallation; missing customization permission + preserves the native Stop button with its usual name. + - Fix Slack Settings rejecting the MCP, Skills and You pages with `invalid_arguments` when their multi-select pickers contain options. diff --git a/FEATURES.md b/FEATURES.md index 7866bca..cda079e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1607,8 +1607,15 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. (`agents.sessions.setStatus`: `processing` while a turn works, `active` when it ends — asserted once per transition and re-asserted every five minutes so a long turn never hits Slack's one-hour `processing` expiry) and then writes the activity phrase through the legacy - `assistant.threads.setStatus` bridge, which is the only surface that accepts a phrase. The app - manifest subscribes to `agent_session_stopped` (and `agent_session_title_changed`), which is what + `assistant.threads.setStatus` bridge, which is the only surface that accepts a phrase. The + session's display name follows the resolved model, so Slack's control reads **Stop Opus 1M** + or **Stop GPT-6.1 Sol** instead of the app name. Runtime changes update it immediately; + separate threads retain separate names. Before resolution it says **AI agent**, or the engine's + **CLI default** when no model is reported. This uses the per-session `username` override and + requires `chat:write.customize` (add the scope and reinstall existing apps). A rejected override + retries the same native lifecycle without customization, preserving Stop; a missing scope is + retried after one minute without requiring a daemon restart. The global bot name is unchanged. + The app manifest subscribes to `agent_session_stopped` (and `agent_session_title_changed`), which is what makes Slack render its native **Stop** button on the thread while the session is `processing`, in DMs and in channel threads alike. Pressing it stops exactly that thread through the same `stopRunsInChannel` path and authorization gate as a `stop` message or 🛑 reaction (redelivered diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 8b91c44..dadadc8 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -7737,6 +7737,19 @@ Claude and on Codex. `references/tables.md`. ### Slack Agent Sessions & native Stop +- [x] Automated: resolved model names reach the native session username, update immediately on + runtime changes, remain separate across threads, and survive the terminal clear. Ordinary + tool phases do not repeat native writes. Reused threads reset to AI agent before model + resolution; an unreported model says CLI default. Missing customization scope retries the + bare native lifecycle and retries customization after a minute; an invalid name does not + disable native Stop or other model names. +- [ ] Live, Claude and Codex, with `chat:write.customize` installed: run an owned sleeping turn. + Native Stop shows the resolved model name. Change the next turn's model and verify the new + label; observe an authorized unpinned failover and simultaneous threads without stale or + cross-thread names. Native Stop still cancels, clears status, and prevents late answers. + An isolated missing-customize fixture retains native Stop and model information in progress. + Verify an unreported CLI default does not invent a concrete model. Actual native rendering + is required; API payload inspection alone does not pass this gate. - [x] Automated (`test/slack-agent-sessions.test.js`): a status write calls `agents.sessions.setStatus processing` once, carries the phrase and loading messages on `assistant.threads.setStatus`, does not repeat the native write for a phrase change, sends diff --git a/slack-app-manifest.json b/slack-app-manifest.json index 1c02736..ae07c65 100644 --- a/slack-app-manifest.json +++ b/slack-app-manifest.json @@ -68,6 +68,7 @@ "channels:history", "channels:read", "chat:write", + "chat:write.customize", "commands", "files:read", "files:write", diff --git a/src/slack/agent-sessions.js b/src/slack/agent-sessions.js index 6707c28..3f60ca0 100644 --- a/src/slack/agent-sessions.js +++ b/src/slack/agent-sessions.js @@ -27,6 +27,10 @@ const NATIVE_REASSERT_MS = 5 * 60_000; // refused this thread. Bounded by time so a long-lived daemon cannot grow it without limit. const THREAD_TTL_MS = 2 * 60 * 60_000; const threads = new Map(); +// A missing identity scope must never disable the native lifecycle/Stop button. Retry identity +// customization later, so an app reinstall can take effect without restarting the daemon. +const CUSTOMIZE_RETRY_MS = 60_000; +let customization = new WeakMap(); function threadState(key) { const now = Date.now(); let state = threads.get(key); @@ -89,6 +93,7 @@ export function resetAgentSessionsMode() { modes.status = "native"; modes.rename = "native"; threads.clear(); + customization = new WeakMap(); } function errorCode(error) { @@ -108,20 +113,45 @@ function noteFallback(surface, error) { // means the session is `processing`, an empty one `active`. `loadingMessages` are the prominent // loading copy (already normalized by the caller). Resolves true when Slack accepted the write on // at least one surface, false when this thread can carry no status at all. -export async function setAgentSessionStatus(client, { channel, threadTs, phrase = "", loadingMessages = [], status = "" } = {}) { +export async function setAgentSessionStatus(client, { channel, threadTs, phrase = "", loadingMessages = [], status = "", username = "" } = {}) { if (!threadTs) return false; const state = threadState(`${channel}:${threadTs}`); const nativeStatus = status || (phrase ? SESSION_STATUS.processing : SESSION_STATUS.active); let nativeAccepted = false; if (modes.status === "native" && !state.nativeOff) { const now = Date.now(); + const name = [...String(username || "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim()].slice(0, 200).join(""); + const identity = customization.get(client); + const customize = name && now >= (identity?.retryAfter || 0) && state.refusedUsername !== name; + const nameChanged = customize && state.username !== name; // Asserted on every transition; a steady `processing` is re-asserted on a slow cadence so a // long turn never hits the hour expiry, and a steady `active` is never repeated. - const due = state.native !== nativeStatus + const due = state.native !== nativeStatus || nameChanged || (nativeStatus === SESSION_STATUS.processing && now - state.nativeAt >= NATIVE_REASSERT_MS); if (due) { try { - await client.apiCall("agents.sessions.setStatus", { channel_id: channel, thread_ts: threadTs, status: nativeStatus }); + const payload = { channel_id: channel, thread_ts: threadTs, status: nativeStatus }; + if (customize) { + try { + await client.apiCall("agents.sessions.setStatus", { ...payload, username: name }); + state.username = name; + state.refusedUsername = ""; + customization.delete(client); + } catch (error) { + const code = errorCode(error); + if (!["missing_scope", "invalid_arguments", "invalid_arg_name"].includes(code)) throw error; + if (code === "missing_scope") { + customization.set(client, { retryAfter: now + CUSTOMIZE_RETRY_MS, warned: true }); + if (!identity?.warned) console.warn("[slack] native model names need chat:write.customize; add the scope and reinstall the app. Native Stop remains enabled."); + } else { + // A bad label belongs to this session, not every other model or thread. + state.refusedUsername = name; + } + await client.apiCall("agents.sessions.setStatus", payload); + } + } else { + await client.apiCall("agents.sessions.setStatus", payload); + } state.native = nativeStatus; state.nativeAt = now; nativeAccepted = true; diff --git a/src/slack/progress.js b/src/slack/progress.js index 1780372..c383fe0 100644 --- a/src/slack/progress.js +++ b/src/slack/progress.js @@ -7,7 +7,7 @@ import { createTtlSet, isSlackInvalidBlocksError, postChunkedReply, MAX_SLACK_CH import { footerText, footerButtons, footerBlocks } from "./footer.js"; import { answerImageBlocks, shareAnswerImageFiles } from "./images.js"; import { setAgentSessionStatus, nativeSessionStatus, SESSION_STATUS } from "./agent-sessions.js"; -import { modelLabel } from "../gateway/model-info.js"; +import { modelLabel, modelDisplayLabel } from "../gateway/model-info.js"; import { engineLabel } from "../engines/registry.js"; import { describeSilence } from "../engines/watchdog.js"; @@ -50,7 +50,7 @@ function normalizeLoadingMessage(message) { // A non-empty `status` means the turn is working (the agent session goes `processing`, which is // what shows Slack's native Stop button); "" clears it (`active`). The phrase and the loading // messages only reach the legacy surface — see agent-sessions.js for the two surfaces. -export function setAssistantStatus(client, channel, threadTs, status, loadingMessages = []) { +export function setAssistantStatus(client, channel, threadTs, status, loadingMessages = [], { username = status ? "AI agent" : "" } = {}) { if (!threadTs) return Promise.resolve(false); const key = `${channel}:${threadTs}`; // A CLEAR is never gated: a thread that refused an activity write may still hold a native @@ -60,7 +60,7 @@ export function setAssistantStatus(client, channel, threadTs, status, loadingMes const normalizedLoadingMessages = loadingMessages .slice(0, SLACK_LOADING_MESSAGE_MAX_COUNT) .map(normalizeLoadingMessage); - return setAgentSessionStatus(client, { channel, threadTs, phrase: status, loadingMessages: normalizedLoadingMessages }) + return setAgentSessionStatus(client, { channel, threadTs, phrase: status, loadingMessages: normalizedLoadingMessages, username }) .then((accepted) => { // Not an assistant thread / not a member / unusable thread_ts — fall back to the placeholder // edit, and stop asking for this thread. @@ -156,6 +156,7 @@ function activeMarkdownFence(text) { function startStatusAnimation(client, channel, threadTs) { let activity = "is gathering information…"; let runtime = ""; + let nativeName = "AI agent"; let rendered = ""; let closed = false; let disabled = false; @@ -184,16 +185,16 @@ function startStatusAnimation(client, channel, threadTs) { // and therefore the answer, waiting at the end of that backlog. There is now at most ONE write // in flight and ONE pending write, and the pending slot always holds the LATEST status: a // phrase superseded before it ever reached Slack is dropped instead of being sent late. - const write = async (status, loadingMessages) => { + const write = async (status, loadingMessages, username) => { if (disabled && status) return; - const accepted = await setAssistantStatus(client, channel, threadTs, status, loadingMessages); + const accepted = await setAssistantStatus(client, channel, threadTs, status, loadingMessages, { username }); if (!accepted && status) disabled = true; }; const pump = () => { if (inFlight || !pending) return; const next = pending; pending = null; - inFlight = write(next.status, next.loadingMessages) + inFlight = write(next.status, next.loadingMessages, next.username) .catch(() => {}) .then(() => { inFlight = null; @@ -207,7 +208,7 @@ function startStatusAnimation(client, channel, threadTs) { if (pending) pending.done(); // superseded before it was ever sent let done; const written = new Promise((resolve) => { done = resolve; }); - pending = { status, loadingMessages, done }; + pending = { status, loadingMessages, username: nativeName, done }; pump(); return written; }; @@ -293,6 +294,7 @@ function startStatusAnimation(client, channel, threadTs) { runtime = model ? modelLabel({ engine, model }) : `${engineLabel(engine) || engine} CLI default`; + nativeName = model ? modelDisplayLabel(model, engine) : runtime; render(); }, afterMessageActivity, @@ -311,7 +313,7 @@ function startStatusAnimation(client, channel, threadTs) { if (pending) pending.done(); let done; terminalClear = new Promise((resolve) => { done = resolve; }); - pending = { status: "", loadingMessages: [], done }; + pending = { status: "", loadingMessages: [], username: nativeName, done }; pump(); return terminalClear; }, diff --git a/test/channelgate-rename.test.js b/test/channelgate-rename.test.js index d22489b..a5e2279 100644 --- a/test/channelgate-rename.test.js +++ b/test/channelgate-rename.test.js @@ -281,7 +281,7 @@ test("the Slack app manifest is valid JSON, renamed, and keeps its scopes and ev assert.equal(raw.includes(OLD_NAME), false); // Scopes and events are the app's contract with an existing install — the rename must not move // a single one of them. - assert.equal(manifest.oauth_config.scopes.bot.length, 21); + assert.equal(manifest.oauth_config.scopes.bot.length, 22); for (const scope of ["app_mentions:read", "chat:write", "commands", "lists:write", "users:read"]) { assert.ok(manifest.oauth_config.scopes.bot.includes(scope), scope); } diff --git a/test/slack-agent-sessions.test.js b/test/slack-agent-sessions.test.js index a8000aa..9364292 100644 --- a/test/slack-agent-sessions.test.js +++ b/test/slack-agent-sessions.test.js @@ -19,6 +19,57 @@ beforeEach(() => resetAgentSessionsMode()); const slackError = (code) => Object.assign(new Error(`An API error occurred: ${code}`), { data: { error: code } }); +test("native model names update immediately, stay per session, and do not repeat for tool phases", async () => { + const calls = []; + const client = { apiCall: async (method, payload) => calls.push([method, payload]) }; + await setAgentSessionStatus(client, { channel: "C_NAMES", threadTs: "1", phrase: "thinking", username: "Opus 1M" }); + await setAgentSessionStatus(client, { channel: "C_NAMES", threadTs: "1", phrase: "using a tool", username: "Opus 1M" }); + await setAgentSessionStatus(client, { channel: "C_NAMES", threadTs: "2", phrase: "thinking", username: "GPT-6.1 Sol" }); + await setAgentSessionStatus(client, { channel: "C_NAMES", threadTs: "1", phrase: "thinking", username: "GPT-6.1 Sol" }); + await setAgentSessionStatus(client, { channel: "C_NAMES", threadTs: "1", username: "GPT-6.1 Sol" }); + assert.deepEqual(calls.filter(([method]) => method === "agents.sessions.setStatus").map(([, p]) => [p.thread_ts, p.status, p.username]), [ + ["1", "processing", "Opus 1M"], ["2", "processing", "GPT-6.1 Sol"], + ["1", "processing", "GPT-6.1 Sol"], ["1", "active", "GPT-6.1 Sol"], + ]); +}); + +test("missing customize scope retries bare native status and recovers after reinstall without restart", async (t) => { + let now = Date.now(); + t.mock.method(Date, "now", () => now); + let missing = true; + const calls = []; + const client = { apiCall: async (method, payload) => { + calls.push([method, payload]); + if (method === "agents.sessions.setStatus" && payload.username && missing) throw slackError("missing_scope"); + } }; + assert.equal(await setAgentSessionStatus(client, { channel: "C_SCOPE", threadTs: "1", phrase: "thinking", username: "Opus" }), true); + await setAgentSessionStatus(client, { channel: "C_SCOPE", threadTs: "1", phrase: "tool", username: "Opus" }); + assert.equal(agentSessionsMode().status, "native"); + assert.equal(nativeSessionStatus("C_SCOPE", "1"), "processing"); + assert.deepEqual(calls.filter(([m]) => m === "agents.sessions.setStatus").map(([, p]) => p.username), ["Opus", undefined]); + missing = false; + now += 60_001; + await setAgentSessionStatus(client, { channel: "C_SCOPE", threadTs: "1", phrase: "tool", username: "Opus" }); + await setAgentSessionStatus(client, { channel: "C_SCOPE", threadTs: "1", username: "Opus" }); + assert.deepEqual(calls.filter(([m]) => m === "agents.sessions.setStatus").map(([, p]) => [p.status, p.username]), [ + ["processing", "Opus"], ["processing", undefined], ["processing", "Opus"], ["active", "Opus"], + ]); +}); + +test("a refused display name does not disable Stop or poison another model", async () => { + const calls = []; + const client = { apiCall: async (method, payload) => { + calls.push([method, payload]); + if (method === "agents.sessions.setStatus" && payload.username === "Bad name") throw slackError("invalid_arguments"); + } }; + await setAgentSessionStatus(client, { channel: "C_BAD", threadTs: "1", phrase: "thinking", username: "Bad name" }); + await setAgentSessionStatus(client, { channel: "C_BAD", threadTs: "1", phrase: "tool", username: "Bad name" }); + assert.equal(nativeSessionStatus("C_BAD", "1"), "processing"); + assert.equal(agentSessionsMode().status, "native"); + await setAgentSessionStatus(client, { channel: "C_BAD", threadTs: "1", phrase: "thinking", username: "Opus" }); + assert.deepEqual(calls.filter(([m]) => m === "agents.sessions.setStatus").map(([, p]) => p.username), ["Bad name", undefined, "Opus"]); +}); + test("a status write asserts the native lifecycle once per transition and carries the phrase on the legacy surface", async () => { const calls = []; const client = { apiCall: async (method, payload) => { calls.push([method, payload]); } }; @@ -248,5 +299,6 @@ test("the manifest subscribes to the agent session events and registers /model", assert.ok(events.includes("agent_session_title_changed")); assert.ok(manifest.oauth_config.scopes.bot.includes("assistant:write")); assert.ok(manifest.oauth_config.scopes.bot.includes("chat:write")); + assert.ok(manifest.oauth_config.scopes.bot.includes("chat:write.customize")); assert.ok(manifest.features.slash_commands.some((command) => command.command === "/model")); }); diff --git a/test/slack-progress.test.js b/test/slack-progress.test.js index 0cce1ba..d9348a1 100644 --- a/test/slack-progress.test.js +++ b/test/slack-progress.test.js @@ -15,7 +15,7 @@ test("local transcription can use Slack's native compact and prominent status fi // The native session lifecycle is asserted first (that is what shows Slack's Stop button), then // the phrase rides the legacy surface, which is the only one that accepts it. assert.deepEqual(calls, [ - ["agents.sessions.setStatus", { channel_id: "C1", thread_ts: "111.222", status: "processing" }], + ["agents.sessions.setStatus", { channel_id: "C1", thread_ts: "111.222", status: "processing", username: "AI agent" }], ["assistant.threads.setStatus", { channel_id: "C1", thread_ts: "111.222", @@ -590,11 +590,11 @@ test("assistant status writes are serialized so the terminal clear is last", asy let inFlight = 0; let maxInFlight = 0; const client = { - apiCall: (_method, payload) => new Promise((resolve) => { + apiCall: (method, payload) => new Promise((resolve) => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); pending.push(() => { - applied.push(payload.status); + applied.push({ method, ...payload }); inFlight -= 1; resolve(); }); @@ -609,6 +609,7 @@ test("assistant status writes are serialized so the terminal clear is last", asy }); progress.onRuntimeResolved({ engine: "claude", model: "claude-opus-4-8" }); progress.onEvent({ kind: "tool_use", name: "Read", target: "progress.js" }); + progress.onRuntimeResolved({ engine: "codex", model: "gpt-6.1-sol" }); let stopped = false; const stopPromise = progress.stop().then(() => { stopped = true; }); @@ -621,7 +622,11 @@ test("assistant status writes are serialized so the terminal clear is last", asy await stopPromise; assert.equal(maxInFlight, 1, "status requests must not race or apply out of order"); - assert.equal(applied.at(-1), "", "no stale activity update may land after the terminal clear"); + assert.equal(applied.at(-1).status, "", "no stale activity update may land after the terminal clear"); + const native = applied.filter((p) => p.method === "agents.sessions.setStatus"); + assert.deepEqual(native.map((p) => [p.status, p.username]), [ + ["processing", "AI agent"], ["active", "GPT-6.1 Sol"], + ], "a coalesced terminal clear carries the latest model and cannot be followed by stale processing"); }); test("an assistant-thread status line carries thinking summaries and the loading line mirrors the activity", async () => { @@ -729,6 +734,33 @@ test("stream progress updates its model label when the runtime falls back to Cod assert.ok(statuses.includes("is gathering information… · gpt-5.4")); assert.ok(loadingMessages.includes("Sonnet 4.6 · Gathering information…")); assert.ok(loadingMessages.includes("gpt-5.4 · Gathering information…")); + const native = calls.filter(([method]) => method === "agents.sessions.setStatus").map(([, payload]) => payload); + assert.ok(native.some((p) => p.status === "processing" && p.username === "Sonnet 4.6")); + assert.ok(native.some((p) => p.status === "processing" && p.username === "GPT-5.4")); + assert.equal(native.at(-1).status, "active"); + assert.equal(native.at(-1).username, "GPT-5.4"); +}); + +test("a reused session starts with an honest generic name, then an engine default if no model is reported", async () => { + const calls = []; + const client = { + apiCall: async (method, payload) => calls.push([method, payload]), + chatStream: () => ({ append: async () => {}, stop: async () => {} }), + chat: { postMessage: async () => {} }, + }; + const options = { authorId: "U1", teamId: "T1" }; + const first = startProgress("stream", client, "C_REUSE_NAME", "1", options); + first.onRuntimeResolved({ engine: "claude", model: "opus[1m]" }); + await cardReady(); + await first.stop(); + const second = startProgress("stream", client, "C_REUSE_NAME", "1", options); + await cardReady(); + second.onRuntimeResolved({ engine: "codex" }); + await cardReady(); + await second.stop(); + const names = calls.filter(([method]) => method === "agents.sessions.setStatus").map(([, p]) => [p.status, p.username]); + assert.deepEqual(names, [["processing", "AI agent"], ["processing", "Opus 1M"], ["active", "Opus 1M"], + ["processing", "AI agent"], ["processing", "Codex CLI default"], ["active", "Codex CLI default"]]); }); test("completed-run footer carries the fixed labelled menu: Files, Variables, Settings", async () => {