compass-agent 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Slack Assistant handler — threadStarted + userMessage.
3
+ *
4
+ * Uses the Bolt Assistant class to intercept all IM thread messages,
5
+ * providing suggested prompts, thread titles, status indicators,
6
+ * and delegating to the stream handler for Claude invocations.
7
+ */
8
+
9
+ import { Assistant } from "@slack/bolt";
10
+ import { randomUUID } from "crypto";
11
+ import {
12
+ getSession, upsertSession, setCwd, getCwdHistory, addCwdHistory,
13
+ addTeaching, getTeachings, removeTeaching, getTeachingCount,
14
+ getWorktree, touchWorktree, upsertWorktree, markWorktreeCleaned,
15
+ } from "../db.ts";
16
+ import {
17
+ detectGitRepo, createWorktree, copyEnvFiles,
18
+ } from "../lib/worktree.ts";
19
+ import { buildSuggestedPrompts } from "../ui/blocks.ts";
20
+ import { handleClaudeStream } from "./stream.ts";
21
+ import { log, logErr } from "../lib/log.ts";
22
+ import type { ActiveProcessMap, Ref } from "../types.ts";
23
+
24
+ const ALLOWED_USERS = new Set(
25
+ (process.env.ALLOWED_USERS || "").split(",").map((s) => s.trim()).filter(Boolean)
26
+ );
27
+
28
+ export function createAssistant(
29
+ activeProcesses: ActiveProcessMap,
30
+ cachedTeamIdRef: Ref<string | null>,
31
+ cachedBotUserIdRef: Ref<string | null>,
32
+ ): Assistant {
33
+ return new Assistant({
34
+ threadStarted: async ({ event, setSuggestedPrompts }: any) => {
35
+ const threadTs = event.assistant_thread?.thread_ts;
36
+ log(null, `Assistant threadStarted: thread=${threadTs}`);
37
+
38
+ const session = threadTs ? getSession(threadTs) : null;
39
+ try {
40
+ await setSuggestedPrompts(buildSuggestedPrompts(session?.cwd ?? null));
41
+ } catch (err: any) {
42
+ logErr(null, `Failed to set suggested prompts: ${err.message}`);
43
+ }
44
+ },
45
+
46
+ userMessage: async ({ message, client, say, setStatus, setTitle, setSuggestedPrompts }: any) => {
47
+ const channelId = message.channel;
48
+ const threadTs = message.thread_ts || message.ts;
49
+ const userText = message.text;
50
+
51
+ log(channelId, `Assistant userMessage: user=${message.user} ts=${message.ts} thread_ts=${threadTs} text="${userText}"`);
52
+
53
+ // ── Guards ──────────────────────────────────────────
54
+ if (message.subtype || message.bot_id) {
55
+ log(channelId, `Skipping: subtype=${message.subtype} bot_id=${message.bot_id}`);
56
+ return;
57
+ }
58
+
59
+ if (ALLOWED_USERS.size > 0 && !ALLOWED_USERS.has(message.user)) {
60
+ log(channelId, `Blocked unauthorized user=${message.user}`);
61
+ return;
62
+ }
63
+
64
+ // ── $cwd command ────────────────────────────────────
65
+ if (userText?.match(/^\$cwd(\s|$)/i)) {
66
+ const pathArg = userText.replace(/^\$cwd\s*/i, "").trim();
67
+
68
+ if (pathArg) {
69
+ if (!getSession(threadTs)) {
70
+ upsertSession(threadTs, "pending");
71
+ }
72
+ setCwd(threadTs, pathArg);
73
+ addCwdHistory(pathArg);
74
+ // Reset session so next message starts fresh (can't --resume in a different CWD)
75
+ upsertSession(threadTs, "pending");
76
+ markWorktreeCleaned(threadTs);
77
+ log(channelId, `CWD set via $cwd to: ${pathArg} (session reset)`);
78
+ try {
79
+ await client.chat.postEphemeral({
80
+ channel: channelId,
81
+ thread_ts: threadTs,
82
+ user: message.user,
83
+ text: `Working directory set to \`${pathArg}\``,
84
+ });
85
+ // Update suggested prompts with CWD context
86
+ await setSuggestedPrompts(buildSuggestedPrompts(pathArg));
87
+ } catch (err: any) {
88
+ logErr(channelId, `Failed to send CWD confirmation: ${err.message}`);
89
+ }
90
+ return;
91
+ }
92
+
93
+ // Bare $cwd — show interactive directory picker
94
+ const history = getCwdHistory();
95
+ log(channelId, `$cwd picker requested, history_count=${history.length}`);
96
+
97
+ const blocks: any[] = [
98
+ { type: "header", text: { type: "plain_text", text: "Set Working Directory" } },
99
+ { type: "divider" },
100
+ ];
101
+
102
+ if (history.length > 0) {
103
+ blocks.push(
104
+ { type: "section", text: { type: "mrkdwn", text: "*Recent directories:*" } },
105
+ {
106
+ type: "actions",
107
+ block_id: "cwd_picker_block",
108
+ elements: [{
109
+ type: "static_select",
110
+ action_id: "cwd_pick",
111
+ placeholder: { type: "plain_text", text: "Choose a directory..." },
112
+ options: history.map((h) => ({
113
+ text: { type: "plain_text", text: h.path },
114
+ value: JSON.stringify({ path: h.path, threadTs, isTopLevel: false }),
115
+ })),
116
+ }],
117
+ },
118
+ { type: "divider" },
119
+ );
120
+ }
121
+
122
+ blocks.push(
123
+ {
124
+ type: "section",
125
+ text: { type: "mrkdwn", text: "Enter a new path:" },
126
+ accessory: {
127
+ type: "button",
128
+ action_id: "cwd_add_new",
129
+ text: { type: "plain_text", text: "Add new..." },
130
+ style: "primary",
131
+ value: JSON.stringify({ channelId, threadTs }),
132
+ },
133
+ },
134
+ {
135
+ type: "context",
136
+ elements: [
137
+ { type: "mrkdwn", text: "Or send `$cwd /path/to/dir` to set directly" },
138
+ ],
139
+ },
140
+ );
141
+
142
+ try {
143
+ await client.chat.postEphemeral({
144
+ channel: channelId,
145
+ thread_ts: threadTs,
146
+ user: message.user,
147
+ blocks,
148
+ text: "Set working directory",
149
+ });
150
+ log(channelId, `$cwd picker sent (ephemeral to user=${message.user})`);
151
+ } catch (err: any) {
152
+ logErr(channelId, `Failed to send $cwd picker: ${err.message}`);
153
+ }
154
+ return;
155
+ }
156
+
157
+ // ── $teach command ──────────────────────────────────
158
+ if (userText?.match(/^\$teach(\s|$)/i)) {
159
+ const teachArg = userText.replace(/^\$teach\s*/i, "").trim();
160
+ log(channelId, `$teach command: arg="${teachArg || "(empty)"}" user=${message.user}`);
161
+
162
+ if (!teachArg || teachArg === "help") {
163
+ log(channelId, `$teach: showing help`);
164
+ try {
165
+ await client.chat.postMessage({
166
+ channel: channelId,
167
+ thread_ts: threadTs,
168
+ blocks: [
169
+ { type: "header", text: { type: "plain_text", text: "Team Knowledge Base" } },
170
+ { type: "divider" },
171
+ { type: "section", text: { type: "mrkdwn", text: "*Usage:*\n\u2022 `$teach <instruction>` \u2014 add a team convention\n\u2022 `$teach list` \u2014 view all active teachings\n\u2022 `$teach remove <id>` \u2014 remove a teaching by ID" } },
172
+ { type: "context", elements: [{ type: "mrkdwn", text: "Teachings are injected into every Claude session as team conventions." }] },
173
+ ],
174
+ text: "Team Knowledge Base help",
175
+ });
176
+ } catch (err: any) {
177
+ logErr(channelId, `$teach help: failed: ${err.message}`);
178
+ }
179
+ return;
180
+ }
181
+
182
+ if (teachArg === "list") {
183
+ const teachings = getTeachings("default");
184
+ log(channelId, `$teach list: ${teachings.length} active teachings`);
185
+ if (teachings.length === 0) {
186
+ try {
187
+ await say("No teachings yet. Add one with `$teach <instruction>`.");
188
+ } catch (err: any) {
189
+ logErr(channelId, `$teach list (empty): failed: ${err.message}`);
190
+ }
191
+ return;
192
+ }
193
+ const list = teachings.map((t) => `*#${t.id}* \u2014 ${t.instruction} _(added by <@${t.added_by}>)_`).join("\n");
194
+ try {
195
+ await client.chat.postMessage({
196
+ channel: channelId,
197
+ thread_ts: threadTs,
198
+ blocks: [
199
+ { type: "header", text: { type: "plain_text", text: "Team Teachings" } },
200
+ { type: "divider" },
201
+ { type: "section", text: { type: "mrkdwn", text: list } },
202
+ { type: "context", elements: [{ type: "mrkdwn", text: `${teachings.length} active teaching(s)` }] },
203
+ ],
204
+ text: `${teachings.length} teachings`,
205
+ });
206
+ } catch (err: any) {
207
+ logErr(channelId, `$teach list: failed: ${err.message}`);
208
+ }
209
+ return;
210
+ }
211
+
212
+ const removeMatch = teachArg.match(/^remove\s+(\d+)$/i);
213
+ if (removeMatch) {
214
+ const id = parseInt(removeMatch[1], 10);
215
+ log(channelId, `$teach remove: id=${id}`);
216
+ removeTeaching(id);
217
+ try {
218
+ await say(`Teaching #${id} removed.`);
219
+ } catch (err: any) {
220
+ logErr(channelId, `$teach remove: failed: ${err.message}`);
221
+ }
222
+ return;
223
+ }
224
+
225
+ // $teach <instruction>
226
+ const instruction = teachArg.replace(/^["']|["']$/g, "");
227
+ log(channelId, `$teach add: "${instruction}" user=${message.user}`);
228
+ addTeaching(instruction, message.user);
229
+ const count = getTeachingCount("default");
230
+ log(channelId, `$teach: now ${count.count} active teaching(s)`);
231
+ try {
232
+ await say(`Learned: _${instruction}_\n(${count.count} active teaching${count.count !== 1 ? "s" : ""})`);
233
+ } catch (err: any) {
234
+ logErr(channelId, `$teach add: failed: ${err.message}`);
235
+ }
236
+ return;
237
+ }
238
+
239
+ // ── Guard: already processing ─────────────────────
240
+ if (activeProcesses.has(threadTs)) {
241
+ const existing = activeProcesses.get(threadTs);
242
+ log(channelId, `Rejecting — already processing (pid=${existing?.pid})`);
243
+ await say("Still processing the previous message...");
244
+ return;
245
+ }
246
+
247
+ // ── Session lookup ────────────────────────────────
248
+ const session = getSession(threadTs);
249
+ let sessionId: string;
250
+ let isResume = false;
251
+
252
+ if (session && session.session_id && session.session_id !== "pending") {
253
+ sessionId = session.session_id;
254
+ isResume = true;
255
+ log(channelId, `Resuming session: ${sessionId}`);
256
+ } else {
257
+ sessionId = randomUUID();
258
+ if (!session) {
259
+ upsertSession(threadTs, "pending");
260
+ }
261
+ log(channelId, `New session: ${sessionId}`);
262
+ }
263
+
264
+ // ── CWD gate ──────────────────────────────────────
265
+ const currentSession = getSession(threadTs);
266
+ if (!currentSession?.cwd) {
267
+ log(channelId, `No CWD set, blocking`);
268
+ try {
269
+ await say("No working directory set. Send `$cwd` to pick one, or `$cwd /path/to/dir` to set directly.");
270
+ await setSuggestedPrompts(buildSuggestedPrompts(null));
271
+ } catch (err: any) {
272
+ logErr(channelId, `CWD gating: failed: ${err.message}`);
273
+ }
274
+ return;
275
+ }
276
+
277
+ // ── Set thread title from first message ───────────
278
+ if (!isResume) {
279
+ const titleText = userText.length > 60 ? userText.slice(0, 57) + "..." : userText;
280
+ try {
281
+ await setTitle(titleText);
282
+ log(channelId, `Thread title set: "${titleText}"`);
283
+ } catch (err: any) {
284
+ logErr(channelId, `Failed to set title: ${err.message}`);
285
+ }
286
+ }
287
+
288
+ // ── Worktree setup ────────────────────────────────
289
+ let spawnCwd = currentSession.cwd;
290
+ const existingWt = getWorktree(threadTs);
291
+ log(channelId, `Worktree lookup: thread=${threadTs} existingWt=${existingWt ? `path=${existingWt.worktree_path} cleaned=${existingWt.cleaned_up}` : "none"}`);
292
+
293
+ if (existingWt && !existingWt.cleaned_up) {
294
+ spawnCwd = existingWt.worktree_path;
295
+ touchWorktree(threadTs);
296
+ log(channelId, `Reusing worktree: ${spawnCwd}`);
297
+ } else {
298
+ const gitInfo = detectGitRepo(currentSession.cwd);
299
+ log(channelId, `Git detection: cwd=${currentSession.cwd} isGit=${gitInfo.isGit} repoRoot=${gitInfo.repoRoot}`);
300
+ if (gitInfo.isGit) {
301
+ try {
302
+ const { worktreePath, branchName } = createWorktree(gitInfo.repoRoot!, threadTs);
303
+ copyEnvFiles(currentSession.cwd, worktreePath);
304
+ upsertWorktree(threadTs, gitInfo.repoRoot!, worktreePath, branchName);
305
+ spawnCwd = worktreePath;
306
+ log(channelId, `Created worktree: ${worktreePath} branch=${branchName}`);
307
+ } catch (err: any) {
308
+ logErr(channelId, `Worktree creation failed, using raw CWD: ${err.message}`);
309
+ }
310
+ } else {
311
+ log(channelId, `Not a git repo, using raw CWD: ${spawnCwd}`);
312
+ }
313
+ }
314
+
315
+ // ── Delegate to stream handler ────────────────────
316
+ await handleClaudeStream({
317
+ channelId,
318
+ threadTs,
319
+ userText,
320
+ userId: message.user,
321
+ client,
322
+ spawnCwd: spawnCwd!,
323
+ isResume,
324
+ sessionId,
325
+ setStatus,
326
+ activeProcesses,
327
+ cachedTeamId: cachedTeamIdRef.value,
328
+ botUserId: cachedBotUserIdRef.value,
329
+ });
330
+ },
331
+ });
332
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import { createCwdModalHandler } from "./cwd-modal.ts";
3
+
4
+ function makeDeps(overrides = {}) {
5
+ return {
6
+ addCwdHistory: mock(() => {}),
7
+ setChannelDefault: mock(() => {}),
8
+ getSession: mock(() => null),
9
+ upsertSession: mock(() => {}),
10
+ setCwd: mock(() => {}),
11
+ markWorktreeCleaned: mock(() => {}),
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ function makeView({ inputVal, selectVal, channelId = "C123", threadTs, isTopLevel = true }: {
17
+ inputVal?: string;
18
+ selectVal?: string;
19
+ channelId?: string;
20
+ threadTs?: string;
21
+ isTopLevel?: boolean;
22
+ }) {
23
+ return {
24
+ private_metadata: JSON.stringify({ channelId, threadTs, isTopLevel }),
25
+ state: {
26
+ values: {
27
+ cwd_input_block: { cwd_input: { value: inputVal ?? null } },
28
+ cwd_select_block: { cwd_select: { selected_option: selectVal ? { value: selectVal } : null } },
29
+ },
30
+ },
31
+ };
32
+ }
33
+
34
+ function makeArgs(view: any, userId = "U456") {
35
+ return {
36
+ view,
37
+ ack: mock(() => Promise.resolve()),
38
+ client: { chat: { postEphemeral: mock(() => Promise.resolve({ ok: true })) } },
39
+ body: { user: { id: userId } },
40
+ };
41
+ }
42
+
43
+ describe("cwd_modal handler", () => {
44
+ it("uses body.user.id (not view.user) for postEphemeral", async () => {
45
+ const deps = makeDeps();
46
+ const handler = createCwdModalHandler(deps);
47
+ const view = makeView({ inputVal: "/projects/foo", isTopLevel: true });
48
+ const args = makeArgs(view, "U_REAL_USER");
49
+
50
+ await handler(args);
51
+
52
+ expect(args.client.chat.postEphemeral).toHaveBeenCalledWith(
53
+ expect.objectContaining({ user: "U_REAL_USER" }),
54
+ );
55
+ });
56
+
57
+ it("uses body.user.id for setChannelDefault on top-level", async () => {
58
+ const deps = makeDeps();
59
+ const handler = createCwdModalHandler(deps);
60
+ const view = makeView({ inputVal: "/code", isTopLevel: true });
61
+ const args = makeArgs(view, "U789");
62
+
63
+ await handler(args);
64
+
65
+ expect(deps.setChannelDefault).toHaveBeenCalledWith("C123", "/code", "U789");
66
+ });
67
+
68
+ it("returns validation error when no path is provided", async () => {
69
+ const deps = makeDeps();
70
+ const handler = createCwdModalHandler(deps);
71
+ const view = makeView({});
72
+ const args = makeArgs(view);
73
+
74
+ await handler(args);
75
+
76
+ expect(args.ack).toHaveBeenCalledWith({
77
+ response_action: "errors",
78
+ errors: { cwd_input_block: "Please enter a path or select one from the dropdown." },
79
+ });
80
+ expect(args.client.chat.postEphemeral).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it("prefers text input over select", async () => {
84
+ const deps = makeDeps();
85
+ const handler = createCwdModalHandler(deps);
86
+ const view = makeView({ inputVal: "/typed/path", selectVal: "/selected/path", isTopLevel: true });
87
+ const args = makeArgs(view);
88
+
89
+ await handler(args);
90
+
91
+ expect(deps.addCwdHistory).toHaveBeenCalledWith("/typed/path");
92
+ expect(args.client.chat.postEphemeral).toHaveBeenCalledWith(
93
+ expect.objectContaining({ text: expect.stringContaining("/typed/path") }),
94
+ );
95
+ });
96
+
97
+ it("falls back to select value when input is empty", async () => {
98
+ const deps = makeDeps();
99
+ const handler = createCwdModalHandler(deps);
100
+ const view = makeView({ selectVal: "/selected/path", isTopLevel: true });
101
+ const args = makeArgs(view);
102
+
103
+ await handler(args);
104
+
105
+ expect(deps.addCwdHistory).toHaveBeenCalledWith("/selected/path");
106
+ });
107
+
108
+ it("sets thread CWD when top-level with threadTs", async () => {
109
+ const deps = makeDeps();
110
+ const handler = createCwdModalHandler(deps);
111
+ const view = makeView({ inputVal: "/code", isTopLevel: true, threadTs: "T100" });
112
+ const args = makeArgs(view);
113
+
114
+ await handler(args);
115
+
116
+ expect(deps.setChannelDefault).toHaveBeenCalled();
117
+ expect(deps.upsertSession).toHaveBeenCalledWith("T100", "pending");
118
+ expect(deps.setCwd).toHaveBeenCalledWith("T100", "/code");
119
+ expect(deps.markWorktreeCleaned).toHaveBeenCalledWith("T100");
120
+ });
121
+
122
+ it("uses threadTs as session key for non-top-level", async () => {
123
+ const deps = makeDeps();
124
+ const handler = createCwdModalHandler(deps);
125
+ const view = makeView({ inputVal: "/code", isTopLevel: false, threadTs: "T200" });
126
+ const args = makeArgs(view);
127
+
128
+ await handler(args);
129
+
130
+ expect(deps.setChannelDefault).not.toHaveBeenCalled();
131
+ expect(deps.setCwd).toHaveBeenCalledWith("T200", "/code");
132
+ expect(deps.markWorktreeCleaned).toHaveBeenCalledWith("T200");
133
+ });
134
+
135
+ it("falls back to channelId as session key when no threadTs", async () => {
136
+ const deps = makeDeps();
137
+ const handler = createCwdModalHandler(deps);
138
+ const view = makeView({ inputVal: "/code", isTopLevel: false, channelId: "C999" });
139
+ const args = makeArgs(view);
140
+
141
+ await handler(args);
142
+
143
+ expect(deps.setCwd).toHaveBeenCalledWith("C999", "/code");
144
+ });
145
+
146
+ it("skips upsertSession if session already exists", async () => {
147
+ const deps = makeDeps({ getSession: mock(() => ({ channel_id: "T300", session_id: "existing" })) });
148
+ const handler = createCwdModalHandler(deps);
149
+ const view = makeView({ inputVal: "/code", isTopLevel: false, threadTs: "T300" });
150
+ const args = makeArgs(view);
151
+
152
+ await handler(args);
153
+
154
+ // upsertSession should only be called once (the reset), not for initial creation
155
+ const calls = (deps.upsertSession as any).mock.calls;
156
+ expect(calls.length).toBe(1);
157
+ expect(calls[0]).toEqual(["T300", "pending"]);
158
+ });
159
+
160
+ it("sends ephemeral with correct channel and thread_ts", async () => {
161
+ const deps = makeDeps();
162
+ const handler = createCwdModalHandler(deps);
163
+ const view = makeView({ inputVal: "/code", isTopLevel: false, channelId: "C555", threadTs: "T555" });
164
+ const args = makeArgs(view, "U111");
165
+
166
+ await handler(args);
167
+
168
+ expect(args.client.chat.postEphemeral).toHaveBeenCalledWith({
169
+ channel: "C555",
170
+ thread_ts: "T555",
171
+ user: "U111",
172
+ text: "Working directory set to `/code`",
173
+ });
174
+ });
175
+
176
+ it("shows 'default for this channel' text for top-level", async () => {
177
+ const deps = makeDeps();
178
+ const handler = createCwdModalHandler(deps);
179
+ const view = makeView({ inputVal: "/code", isTopLevel: true });
180
+ const args = makeArgs(view);
181
+
182
+ await handler(args);
183
+
184
+ expect(args.client.chat.postEphemeral).toHaveBeenCalledWith(
185
+ expect.objectContaining({ text: expect.stringContaining("default for this channel") }),
186
+ );
187
+ });
188
+ });
@@ -0,0 +1,63 @@
1
+ import { log } from "../lib/log.ts";
2
+
3
+ interface CwdModalDeps {
4
+ addCwdHistory: (path: string) => void;
5
+ setChannelDefault: (channelId: string, cwd: string, setBy: string) => void;
6
+ getSession: (key: string) => unknown;
7
+ upsertSession: (key: string, sessionId: string) => void;
8
+ setCwd: (key: string, cwd: string) => void;
9
+ markWorktreeCleaned: (key: string) => void;
10
+ }
11
+
12
+ export function createCwdModalHandler(deps: CwdModalDeps) {
13
+ return async ({ view, ack, client, body }: any) => {
14
+ const meta = JSON.parse(view.private_metadata);
15
+ const { channelId, threadTs, isTopLevel } = meta;
16
+ const values = view.state.values;
17
+
18
+ const inputVal = values.cwd_input_block?.cwd_input?.value;
19
+ const selectVal = values.cwd_select_block?.cwd_select?.selected_option?.value;
20
+ const chosenPath = inputVal || selectVal;
21
+
22
+ if (!chosenPath) {
23
+ await ack({
24
+ response_action: "errors",
25
+ errors: {
26
+ cwd_input_block: "Please enter a path or select one from the dropdown.",
27
+ },
28
+ });
29
+ return;
30
+ }
31
+
32
+ await ack();
33
+
34
+ deps.addCwdHistory(chosenPath);
35
+ if (isTopLevel) {
36
+ deps.setChannelDefault(channelId, chosenPath, body.user.id);
37
+ log(channelId, `Channel default CWD set to: ${chosenPath}`);
38
+ if (threadTs) {
39
+ if (!deps.getSession(threadTs)) deps.upsertSession(threadTs, "pending");
40
+ deps.setCwd(threadTs, chosenPath);
41
+ deps.upsertSession(threadTs, "pending");
42
+ deps.markWorktreeCleaned(threadTs);
43
+ }
44
+ } else {
45
+ const sessionKey = threadTs || channelId;
46
+ if (!deps.getSession(sessionKey)) deps.upsertSession(sessionKey, "pending");
47
+ deps.setCwd(sessionKey, chosenPath);
48
+ deps.upsertSession(sessionKey, "pending");
49
+ deps.markWorktreeCleaned(sessionKey);
50
+ log(channelId, `CWD set to: ${chosenPath} (thread=${threadTs}, session reset)`);
51
+ }
52
+
53
+ const confirmText = isTopLevel
54
+ ? `Working directory set to \`${chosenPath}\` (default for this channel)`
55
+ : `Working directory set to \`${chosenPath}\``;
56
+ await client.chat.postEphemeral({
57
+ channel: channelId,
58
+ thread_ts: threadTs,
59
+ user: body.user.id,
60
+ text: confirmText,
61
+ });
62
+ };
63
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+
3
+ /**
4
+ * Tests for the setStatus wiring used in channel @mentions.
5
+ *
6
+ * The setStatus function built in app.ts processMessage normalizes
7
+ * string/object input and delegates to client.assistant.threads.setStatus.
8
+ * We recreate the same logic here to test it in isolation.
9
+ */
10
+
11
+ function createChannelSetStatus(
12
+ client: any,
13
+ channelId: string,
14
+ threadTs: string,
15
+ ) {
16
+ return async (statusOrOpts: string | { status: string; loading_messages?: string[] }) => {
17
+ const params = typeof statusOrOpts === "string"
18
+ ? { status: statusOrOpts }
19
+ : statusOrOpts;
20
+ await client.assistant.threads.setStatus({
21
+ channel_id: channelId,
22
+ thread_ts: threadTs,
23
+ ...params,
24
+ });
25
+ };
26
+ }
27
+
28
+ describe("channel setStatus", () => {
29
+ test("string input — wraps as { status } and includes channel/thread", async () => {
30
+ const calls: any[] = [];
31
+ const client = {
32
+ assistant: {
33
+ threads: {
34
+ setStatus: mock(async (args: any) => { calls.push(args); }),
35
+ },
36
+ },
37
+ };
38
+
39
+ const setStatus = createChannelSetStatus(client, "C123", "1234.5678");
40
+ await setStatus("is reading files...");
41
+
42
+ expect(calls).toHaveLength(1);
43
+ expect(calls[0]).toEqual({
44
+ channel_id: "C123",
45
+ thread_ts: "1234.5678",
46
+ status: "is reading files...",
47
+ });
48
+ });
49
+
50
+ test("object input — spreads status + loading_messages with channel/thread", async () => {
51
+ const calls: any[] = [];
52
+ const client = {
53
+ assistant: {
54
+ threads: {
55
+ setStatus: mock(async (args: any) => { calls.push(args); }),
56
+ },
57
+ },
58
+ };
59
+
60
+ const setStatus = createChannelSetStatus(client, "C456", "9999.1111");
61
+ await setStatus({
62
+ status: "is thinking...",
63
+ loading_messages: ["Thinking...", "Working on it..."],
64
+ });
65
+
66
+ expect(calls).toHaveLength(1);
67
+ expect(calls[0]).toEqual({
68
+ channel_id: "C456",
69
+ thread_ts: "9999.1111",
70
+ status: "is thinking...",
71
+ loading_messages: ["Thinking...", "Working on it..."],
72
+ });
73
+ });
74
+
75
+ test("empty string clears status", async () => {
76
+ const calls: any[] = [];
77
+ const client = {
78
+ assistant: {
79
+ threads: {
80
+ setStatus: mock(async (args: any) => { calls.push(args); }),
81
+ },
82
+ },
83
+ };
84
+
85
+ const setStatus = createChannelSetStatus(client, "C789", "5555.6666");
86
+ await setStatus("");
87
+
88
+ expect(calls).toHaveLength(1);
89
+ expect(calls[0]).toEqual({
90
+ channel_id: "C789",
91
+ thread_ts: "5555.6666",
92
+ status: "",
93
+ });
94
+ });
95
+
96
+ test("multiple calls accumulate correctly", async () => {
97
+ const calls: any[] = [];
98
+ const client = {
99
+ assistant: {
100
+ threads: {
101
+ setStatus: mock(async (args: any) => { calls.push(args); }),
102
+ },
103
+ },
104
+ };
105
+
106
+ const setStatus = createChannelSetStatus(client, "C100", "1111.2222");
107
+ await setStatus({ status: "is thinking...", loading_messages: ["Thinking..."] });
108
+ await setStatus("is reading files...");
109
+ await setStatus("");
110
+
111
+ expect(calls).toHaveLength(3);
112
+ expect(calls[0].status).toBe("is thinking...");
113
+ expect(calls[0].loading_messages).toEqual(["Thinking..."]);
114
+ expect(calls[1].status).toBe("is reading files...");
115
+ expect(calls[1].loading_messages).toBeUndefined();
116
+ expect(calls[2].status).toBe("");
117
+ });
118
+ });