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

- Fix the agent directory returning an empty list on a connected Slack deployment: register
the live Slack client for discovery and follow disconnect/reconnect. Preserve native Slack
automation delivery, including image uploads, menus and reminder permalinks.

- Update Claude Code to 2.1.306 and offer Sonnet 5.5 in the model picker, with its
native 1M context window and verified external-usage cache rates. Existing saved models remain valid.

Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- Built-in reconciliation refreshes channel metadata at startup and nightly at 03:00 in the host
local timezone without an engine turn or user schedule. Metadata caches narrow keyword searches;
returned recommendations still verify live metadata/membership and fresh gateway access.
The daemon registers its existing Slack lifecycle manager in the live connector registry;
disconnects fail closed and reconnects resolve the current client without retaining old readers.
- Visibility protects the reply's audience as well as its requester. DMs, group DMs, hidden,
archived and Lean agents are excluded. Private destinations cannot appear in public/shared
sources; other results require the whole current source roster to belong to the destination.
Expand Down
8 changes: 8 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Channel agent directory (2026-10-21)

- [x] Production boot integration: `test/agent-directory-slack-boot.test.js` uses the real Slack
manager and `createPlatformTransports({ slack })` with a scratch store and simulated provider.
Search uses its default verifier (no directory/connector override), returns the current channel
and eligible specialist after connect, fails closed after disconnect despite cached metadata,
and uses the replacement client after reconnect. Native automation retains the raw Slack
WebClient for menus, images, prefixes and permalink APIs. Live acceptance must exercise the
actual daemon boot registration, not just an injected membership provider.

- [x] Regression: `test/agent-directory.test.js`, `test/agent-directory-refresh.test.js` and
`test/agent-directory-tools.test.js` exercise live shared grant/template/dependency resolution,
source and destination authorization, personal dependency suppression, audience proof,
Expand Down
2 changes: 1 addition & 1 deletion scripts/security-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const areas = [
{
name: "agent-directory", floor: [98, 83, 95],
include: ["src/gateway/agent-directory.js", "src/gateway/agent-directory-refresh.js", "src/slack/agent-directory.js", "src/mcp/tools/agent-directory.js"],
tests: ["test/agent-directory.test.js", "test/agent-directory-refresh.test.js", "test/agent-directory-tools.test.js"],
tests: ["test/agent-directory.test.js", "test/agent-directory-refresh.test.js", "test/agent-directory-tools.test.js", "test/agent-directory-slack-boot.test.js"],
},
{
name: "plugin-packages", floor: [95, 85, 90],
Expand Down
13 changes: 12 additions & 1 deletion src/platforms/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ async function startTeamsTransport(config, log) {
return startTeams({ ...config, capabilities, getActivationReactions: () => getMentionReactions('msteams'), getActivationVersion: getTeamsMentionReactionsUpdatedAt, onMessage: ingest, onInvoke: controls.onInvoke, onStop: controls.stop, log, deps: { auth, api, connector } });
}

export function createPlatformTransports({ log = console } = {}) {
export function createPlatformTransports({ slack, log = console } = {}) {
// Slack retains its existing lifecycle manager, but also supplies the live connector seam
// used by read-only agent discovery. Resolve the CURRENT client on every access so a
// disconnect or credential rotation can never retain an old metadata/membership reader.
if (slack) registerTransport("slack", {
snapshot: () => slack.snapshot(),
getConnector: () => {
const client = slack.snapshot().connected ? slack.getClient() : null;
const adapter = requirePlatform("slack");
return client ? adapter.createConnector(client, { capabilities: adapter.capabilities }) : null;
},
});
const googlechat = createTransportManager({ platform: "googlechat", log, start: (config, lifecycle) => startGoogleChatTransport(config, log, lifecycle) });
const msteams = createTransportManager({ platform: "msteams", log, start: (config) => startTeamsTransport(config, log) });
registerTransport("googlechat", googlechat);
Expand Down
7 changes: 5 additions & 2 deletions src/platforms/notify.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ export async function postDirectMessage(target, { userId, text, blocks = null }
// Chat/Teams result, and a Slack client must never receive a namespaced foreign conversation id.
export function automationTarget(slackManager, conversationId) {
const platform = platformOfConversation(conversationId);
// Keep native Slack delivery (menus, images, trusted prefixes and permalink APIs) on its
// WebClient path even when Slack also registers a live connector for directory reads.
const client = slackManager?.snapshot?.().connected ? slackManager.getClient?.() : null;
if (asConnector(client)?.platform === platform) return client;
const live = liveConnector(platform);
if (live && live.ready?.() !== false) return live;
const client = slackManager?.snapshot?.().connected ? slackManager.getClient?.() : null;
return asConnector(client)?.platform === platform ? client : null;
return null;
}
2 changes: 1 addition & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ process.env.MCP_TOOL_TIMEOUT ||= "300000";
const slack = createSlackManager();
// Google Chat + Teams. Created at module scope alongside Slack so the managers exist (and are
// registered as the platforms' live-connector source) before anything can ask for a connector.
const transports = createPlatformTransports();
const transports = createPlatformTransports({ slack });

// A long-running gateway must survive a bad integration (e.g. an invalid Slack token producing
// an async error). Log and keep running rather than exiting on these.
Expand Down
73 changes: 73 additions & 0 deletions test/agent-directory-slack-boot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ensureTestEnv } from "./helpers.js";

ensureTestEnv();
const { createSlackManager } = await import("../src/slack/manager.js");
const { createPlatformTransports } = await import("../src/platforms/boot.js");
const { liveConnector, resetTransports } = await import("../src/platforms/live.js");
const { automationTarget } = await import("../src/platforms/notify.js");
const { reconcileAgentDirectory, readAgentDirectoryMetadata } = await import("../src/gateway/agent-directory-refresh.js");
const { searchAgentDirectory } = await import("../src/gateway/agent-directory.js");
const { setUser, upsertChannelEntry, defaultChannelMeta, saveChannelMeta } = await import("../src/config/store.js");
const sourceId = "C_BOOT_SOURCE";
const specialistId = "C_BOOT_SPECIALIST";
const authorId = "U_BOOT_READER";

function webClient(purpose) {
const calls = [];
return { calls,
chat: { postMessage: async () => ({ channel: sourceId, ts: "1.1" }), getPermalink: async () => ({ permalink: "https://slack.example/thread" }) },
conversations: {
info: async ({ channel }) => {
calls.push(channel);
return { ok: true, channel: { id: channel, name: channel === sourceId ? "boot-source" : "boot-specialist", is_channel: true, is_private: true, purpose: { value: purpose } } };
},
members: async () => ({ ok: true, members: [authorId, "U_BOOT_BOT"], response_metadata: {} }),
},
};
}

test("daemon transport boot exposes Slack discovery and follows disconnect/reconnect without changing native automation delivery", async () => {
resetTransports();
await setUser(authorId, { approved: true });
for (const [channelId, name] of [[sourceId, "boot-source"], [specialistId, "boot-specialist"]]) {
const entry = await upsertChannelEntry(channelId, { name, type: "group", isDM: false });
await saveChannelMeta(entry.slug, defaultChannelMeta({ channelId, name, type: "group", isDM: false }));
}
const first = webClient("Presentation specialist");
const second = webClient("Updated presentation specialist");
let next = first;
const manager = createSlackManager({ start: async () => ({ app: { client: next, stop: async () => {} }, teamId: "T_BOOT", botUserId: "U_BOOT_BOT" }) });
// This is the same boot entry the daemon calls, not an injected directory verifier/provider.
createPlatformTransports({ slack: manager });
const lookup = () => searchAgentDirectory({ createdBy: authorId, channelId: sourceId, limit: 50 });
try {
assert.equal(liveConnector("slack"), null);
assert.equal((await lookup()).returned, 0);
await manager.connect({});
assert.equal(liveConnector("slack").client, first);
assert.equal(automationTarget(manager, sourceId), first, "native Slack delivery keeps menus, images and getPermalink");
const initial = await lookup();
assert.deepEqual(initial.agents.map((entry) => entry.channelId).sort(), [sourceId, specialistId].sort());
assert.equal(initial.agents.find((entry) => entry.channelId === specialistId).description, "Presentation specialist");
assert.match(initial.agents[0].url, /^https:\/\/slack\.com\/app_redirect/);
const refresh = await reconcileAgentDirectory();
assert.equal(refresh.updated, 2);
assert.equal(readAgentDirectoryMetadata()[specialistId].purpose, "Presentation specialist");
await manager.disconnect();
assert.equal(liveConnector("slack"), null);
assert.equal(automationTarget(manager, sourceId), null);
assert.equal((await lookup()).returned, 0, "cache never substitutes for disconnected live proof");
const oldReads = first.calls.length;
next = second;
await manager.connect({});
assert.equal(automationTarget(manager, sourceId), second);
const updated = await lookup();
assert.equal(updated.agents.find((entry) => entry.channelId === specialistId).description, "Updated presentation specialist");
assert.equal(first.calls.length, oldReads, "old credentials/client are not retained by the registry");
} finally {
await manager.disconnect();
resetTransports();
}
});
Loading