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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 9 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions slack-app-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"channels:history",
"channels:read",
"chat:write",
"chat:write.customize",
"commands",
"files:read",
"files:write",
Expand Down
36 changes: 33 additions & 3 deletions src/slack/agent-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -89,6 +93,7 @@ export function resetAgentSessionsMode() {
modes.status = "native";
modes.rename = "native";
threads.clear();
customization = new WeakMap();
}

function errorCode(error) {
Expand All @@ -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;
Expand Down
18 changes: 10 additions & 8 deletions src/slack/progress.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
};
Expand Down Expand Up @@ -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,
Expand All @@ -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;
},
Expand Down
2 changes: 1 addition & 1 deletion test/channelgate-rename.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
52 changes: 52 additions & 0 deletions test/slack-agent-sessions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]); } };
Expand Down Expand Up @@ -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"));
});
Loading
Loading