discord-agent-bridge 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/app.js +210 -0
- package/dist/app.js.map +1 -0
- package/dist/app.test.js +117 -0
- package/dist/app.test.js.map +1 -0
- package/dist/cli.js +63 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.js +91 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/core/auditLog.js +65 -0
- package/dist/core/auditLog.js.map +1 -0
- package/dist/core/auditLog.test.js +124 -0
- package/dist/core/auditLog.test.js.map +1 -0
- package/dist/core/auth.js +106 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/auth.test.js +216 -0
- package/dist/core/auth.test.js.map +1 -0
- package/dist/core/channelRegistry.js +119 -0
- package/dist/core/channelRegistry.js.map +1 -0
- package/dist/core/channelRegistry.test.js +114 -0
- package/dist/core/channelRegistry.test.js.map +1 -0
- package/dist/core/commandPolicy.js +6 -0
- package/dist/core/commandPolicy.js.map +1 -0
- package/dist/core/commandRouter.js +9 -0
- package/dist/core/commandRouter.js.map +1 -0
- package/dist/core/config.js +141 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.js +149 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/configResolver.js +90 -0
- package/dist/core/configResolver.js.map +1 -0
- package/dist/core/configResolver.test.js +138 -0
- package/dist/core/configResolver.test.js.map +1 -0
- package/dist/core/configSchema.js +164 -0
- package/dist/core/configSchema.js.map +1 -0
- package/dist/core/contracts.js +5 -0
- package/dist/core/contracts.js.map +1 -0
- package/dist/core/eventBus.js +42 -0
- package/dist/core/eventBus.js.map +1 -0
- package/dist/core/eventBus.test.js +72 -0
- package/dist/core/eventBus.test.js.map +1 -0
- package/dist/core/hookBridge.js +11 -0
- package/dist/core/hookBridge.js.map +1 -0
- package/dist/core/logger.js +116 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/logger.test.js +88 -0
- package/dist/core/logger.test.js.map +1 -0
- package/dist/core/modeRegistry.js +33 -0
- package/dist/core/modeRegistry.js.map +1 -0
- package/dist/core/permissionResolver.js +43 -0
- package/dist/core/permissionResolver.js.map +1 -0
- package/dist/core/permissionResolver.test.js +136 -0
- package/dist/core/permissionResolver.test.js.map +1 -0
- package/dist/core/providerCatalog.js +240 -0
- package/dist/core/providerCatalog.js.map +1 -0
- package/dist/core/providerCatalog.test.js +230 -0
- package/dist/core/providerCatalog.test.js.map +1 -0
- package/dist/core/sessionOrchestrator.js +463 -0
- package/dist/core/sessionOrchestrator.js.map +1 -0
- package/dist/core/sessionOrchestrator.test.js +344 -0
- package/dist/core/sessionOrchestrator.test.js.map +1 -0
- package/dist/core/state/schema.js +49 -0
- package/dist/core/state/schema.js.map +1 -0
- package/dist/core/state/store.js +86 -0
- package/dist/core/state/store.js.map +1 -0
- package/dist/core/state/store.test.js +99 -0
- package/dist/core/state/store.test.js.map +1 -0
- package/dist/core/usageService.js +310 -0
- package/dist/core/usageService.js.map +1 -0
- package/dist/core/usageService.test.js +234 -0
- package/dist/core/usageService.test.js.map +1 -0
- package/dist/discord/alwaysAllow.test.js +181 -0
- package/dist/discord/alwaysAllow.test.js.map +1 -0
- package/dist/discord/client.js +666 -0
- package/dist/discord/client.js.map +1 -0
- package/dist/discord/client.test.js +256 -0
- package/dist/discord/client.test.js.map +1 -0
- package/dist/discord/configPanel.js +270 -0
- package/dist/discord/configPanel.js.map +1 -0
- package/dist/discord/configPanel.test.js +332 -0
- package/dist/discord/configPanel.test.js.map +1 -0
- package/dist/discord/directoryBrowser.js +151 -0
- package/dist/discord/directoryBrowser.js.map +1 -0
- package/dist/discord/directoryBrowser.test.js +163 -0
- package/dist/discord/directoryBrowser.test.js.map +1 -0
- package/dist/discord/favorites.js +10 -0
- package/dist/discord/favorites.js.map +1 -0
- package/dist/discord/fileDownload.js +94 -0
- package/dist/discord/fileDownload.js.map +1 -0
- package/dist/discord/fileDownload.test.js +59 -0
- package/dist/discord/fileDownload.test.js.map +1 -0
- package/dist/discord/format.js +104 -0
- package/dist/discord/format.js.map +1 -0
- package/dist/discord/format.test.js +40 -0
- package/dist/discord/format.test.js.map +1 -0
- package/dist/discord/guildChannels.js +97 -0
- package/dist/discord/guildChannels.js.map +1 -0
- package/dist/discord/guildChannels.test.js +231 -0
- package/dist/discord/guildChannels.test.js.map +1 -0
- package/dist/discord/i18n.js +207 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/i18n.test.js +31 -0
- package/dist/discord/i18n.test.js.map +1 -0
- package/dist/discord/interactionRouter.js +954 -0
- package/dist/discord/interactionRouter.js.map +1 -0
- package/dist/discord/interactionRouter.test.js +1245 -0
- package/dist/discord/interactionRouter.test.js.map +1 -0
- package/dist/discord/messageRouter.js +221 -0
- package/dist/discord/messageRouter.js.map +1 -0
- package/dist/discord/messageRouter.test.js +360 -0
- package/dist/discord/messageRouter.test.js.map +1 -0
- package/dist/discord/ports.js +12 -0
- package/dist/discord/ports.js.map +1 -0
- package/dist/discord/renderers/defaultSet.test.js +97 -0
- package/dist/discord/renderers/defaultSet.test.js.map +1 -0
- package/dist/discord/renderers/diffView.js +73 -0
- package/dist/discord/renderers/diffView.js.map +1 -0
- package/dist/discord/renderers/diffView.test.js +54 -0
- package/dist/discord/renderers/diffView.test.js.map +1 -0
- package/dist/discord/renderers/index.js +143 -0
- package/dist/discord/renderers/index.js.map +1 -0
- package/dist/discord/renderers/index.test.js +124 -0
- package/dist/discord/renderers/index.test.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.js +19 -0
- package/dist/discord/renderers/mentionOnComplete.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.test.js +35 -0
- package/dist/discord/renderers/mentionOnComplete.test.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.js +150 -0
- package/dist/discord/renderers/permissionButtons.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.test.js +108 -0
- package/dist/discord/renderers/permissionButtons.test.js.map +1 -0
- package/dist/discord/renderers/resultLine.js +25 -0
- package/dist/discord/renderers/resultLine.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.js +20 -0
- package/dist/discord/renderers/statusEmbed.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.test.js +32 -0
- package/dist/discord/renderers/statusEmbed.test.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.js +134 -0
- package/dist/discord/renderers/streamEmbed.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.test.js +97 -0
- package/dist/discord/renderers/streamEmbed.test.js.map +1 -0
- package/dist/discord/renderers/toolThread.js +65 -0
- package/dist/discord/renderers/toolThread.js.map +1 -0
- package/dist/discord/renderers/toolThread.test.js +54 -0
- package/dist/discord/renderers/toolThread.test.js.map +1 -0
- package/dist/discord/renderers/transcriptFeed.js +33 -0
- package/dist/discord/renderers/transcriptFeed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.js +78 -0
- package/dist/discord/renderers/usageEmbed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.test.js +42 -0
- package/dist/discord/renderers/usageEmbed.test.js.map +1 -0
- package/dist/discord/wiring.js +214 -0
- package/dist/discord/wiring.js.map +1 -0
- package/dist/discord/wiring.test.js +252 -0
- package/dist/discord/wiring.test.js.map +1 -0
- package/dist/discord/wizard/channelWizard.js +305 -0
- package/dist/discord/wizard/channelWizard.js.map +1 -0
- package/dist/discord/wizard/channelWizard.test.js +298 -0
- package/dist/discord/wizard/channelWizard.test.js.map +1 -0
- package/dist/discord/wizard/resumeWizard.js +123 -0
- package/dist/discord/wizard/resumeWizard.js.map +1 -0
- package/dist/modes/claude/index.js +80 -0
- package/dist/modes/claude/index.js.map +1 -0
- package/dist/modes/claude/listResumable.test.js +72 -0
- package/dist/modes/claude/listResumable.test.js.map +1 -0
- package/dist/modes/claude/mcpFileTool.js +82 -0
- package/dist/modes/claude/mcpFileTool.js.map +1 -0
- package/dist/modes/claude/permissions.js +35 -0
- package/dist/modes/claude/permissions.js.map +1 -0
- package/dist/modes/claude/plugins.js +56 -0
- package/dist/modes/claude/plugins.js.map +1 -0
- package/dist/modes/claude/session.js +324 -0
- package/dist/modes/claude/session.js.map +1 -0
- package/dist/modes/claude/session.test.js +415 -0
- package/dist/modes/claude/session.test.js.map +1 -0
- package/dist/modes/codex/discovery.js +221 -0
- package/dist/modes/codex/discovery.js.map +1 -0
- package/dist/modes/codex/discovery.test.js +213 -0
- package/dist/modes/codex/discovery.test.js.map +1 -0
- package/dist/modes/codex/eventMapper.js +262 -0
- package/dist/modes/codex/eventMapper.js.map +1 -0
- package/dist/modes/codex/index.js +132 -0
- package/dist/modes/codex/index.js.map +1 -0
- package/dist/modes/codex/index.test.js +170 -0
- package/dist/modes/codex/index.test.js.map +1 -0
- package/dist/modes/codex/runner.js +207 -0
- package/dist/modes/codex/runner.js.map +1 -0
- package/dist/modes/codex/runner.test.js +587 -0
- package/dist/modes/codex/runner.test.js.map +1 -0
- package/dist/modes/codex/sqliteReader.js +171 -0
- package/dist/modes/codex/sqliteReader.js.map +1 -0
- package/dist/modes/codex/sqliteReader.test.js +137 -0
- package/dist/modes/codex/sqliteReader.test.js.map +1 -0
- package/dist/setup/wizard.js +115 -0
- package/dist/setup/wizard.js.map +1 -0
- package/dist/setup/wizard.test.js +216 -0
- package/dist/setup/wizard.test.js.map +1 -0
- package/dist/startBot.test.js +78 -0
- package/dist/startBot.test.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { InteractionRouter, } from './interactionRouter.js';
|
|
6
|
+
import { ConfigStore } from '../core/config.js';
|
|
7
|
+
import { CONFIG_VERSION } from '../core/configSchema.js';
|
|
8
|
+
import { StateStore } from '../core/state/store.js';
|
|
9
|
+
import { ChannelRegistry } from '../core/channelRegistry.js';
|
|
10
|
+
import { ConfigResolver } from '../core/configResolver.js';
|
|
11
|
+
import { PermissionResolver } from '../core/permissionResolver.js';
|
|
12
|
+
import { ModeRegistry } from '../core/modeRegistry.js';
|
|
13
|
+
import { Authorizer } from '../core/auth.js';
|
|
14
|
+
import { createLogger } from '../core/logger.js';
|
|
15
|
+
const logger = createLogger('test', { level: 'error', sink: { write() { } } });
|
|
16
|
+
const CLAUDE_CAPS = {
|
|
17
|
+
streaming: true,
|
|
18
|
+
thinking: true,
|
|
19
|
+
toolThreads: true,
|
|
20
|
+
permissionPrompts: true,
|
|
21
|
+
progress: false,
|
|
22
|
+
transcript: false,
|
|
23
|
+
sessionResume: true,
|
|
24
|
+
fileAttach: true,
|
|
25
|
+
fileDiff: true,
|
|
26
|
+
usagePanel: true,
|
|
27
|
+
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
|
28
|
+
};
|
|
29
|
+
// A no-op AgentMode used only for its name + capabilities in the router. `resumable`
|
|
30
|
+
// scripts what listResumable returns (per-backend, for the resume-flow tests); when
|
|
31
|
+
// undefined, listResumable is absent so the mode simply has no resumable list.
|
|
32
|
+
class StubMode {
|
|
33
|
+
name;
|
|
34
|
+
capabilities;
|
|
35
|
+
resumable;
|
|
36
|
+
constructor(name, capabilities, resumable) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.capabilities = capabilities;
|
|
39
|
+
this.resumable = resumable;
|
|
40
|
+
if (this.resumable !== undefined) {
|
|
41
|
+
this.listResumable = async (_ctx) => this.resumable;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
listResumable;
|
|
45
|
+
async start(_ctx) {
|
|
46
|
+
return { sessionId: `${this.name}-sess`, async send() { }, async stop() { } };
|
|
47
|
+
}
|
|
48
|
+
async resume(_ctx, id) {
|
|
49
|
+
return { sessionId: id, async send() { }, async stop() { } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const ADMIN_ROLE = 'role-admin';
|
|
53
|
+
const EXEC_ROLE = 'role-exec';
|
|
54
|
+
function writeConfig(dir) {
|
|
55
|
+
const config = {
|
|
56
|
+
version: CONFIG_VERSION,
|
|
57
|
+
discord: { token: 'x', clientId: 'cid' },
|
|
58
|
+
auth: {
|
|
59
|
+
adminRoleIds: [ADMIN_ROLE],
|
|
60
|
+
executeRoleIds: [EXEC_ROLE],
|
|
61
|
+
readOnlyRoleIds: [],
|
|
62
|
+
dmPolicy: 'deny',
|
|
63
|
+
},
|
|
64
|
+
defaults: {
|
|
65
|
+
mode: 'claude',
|
|
66
|
+
claudeModel: 'opus',
|
|
67
|
+
codexModel: '',
|
|
68
|
+
permissionMode: 'default',
|
|
69
|
+
permissionProfile: null,
|
|
70
|
+
codexHome: '~/.codex',
|
|
71
|
+
codexCliCommand: 'codex',
|
|
72
|
+
codexCliVersion: null,
|
|
73
|
+
},
|
|
74
|
+
limits: { maxSessionsPerUser: 0, permissionTimeoutSec: 60, codexTimeoutMs: 1_800_000 },
|
|
75
|
+
policy: { unknownCommand: 'confirm', allowExtraCommands: [] },
|
|
76
|
+
autoAllowClaudeTools: ['Read'],
|
|
77
|
+
profiles: {},
|
|
78
|
+
usage: { userAgent: 'claude-code', cacheSec: 180 },
|
|
79
|
+
audit: { channelId: null },
|
|
80
|
+
locale: 'ko',
|
|
81
|
+
logLevel: 'info',
|
|
82
|
+
favorites: [],
|
|
83
|
+
};
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config));
|
|
86
|
+
}
|
|
87
|
+
function binding(cwd, over = {}) {
|
|
88
|
+
return {
|
|
89
|
+
guildId: 'g1',
|
|
90
|
+
channelId: 'c1',
|
|
91
|
+
mode: 'claude',
|
|
92
|
+
sessionId: 's1',
|
|
93
|
+
cwd,
|
|
94
|
+
ownerId: 'owner',
|
|
95
|
+
permMode: 'default',
|
|
96
|
+
profile: null,
|
|
97
|
+
archived: false,
|
|
98
|
+
createdAt: 'now',
|
|
99
|
+
updatedAt: 'now',
|
|
100
|
+
...over,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// A recording SessionWiring double.
|
|
104
|
+
function fakeWiring() {
|
|
105
|
+
const calls = {
|
|
106
|
+
attach: vi.fn(async (_g, _c, _m) => { }),
|
|
107
|
+
detach: vi.fn((_g, _c) => { }),
|
|
108
|
+
resolvePermission: vi.fn(async (_g, _c, _id, _actor) => ({ behavior: 'allow' })),
|
|
109
|
+
};
|
|
110
|
+
return { wiring: calls, calls };
|
|
111
|
+
}
|
|
112
|
+
// A recording orchestrator double.
|
|
113
|
+
function fakeOrchestrator() {
|
|
114
|
+
const calls = {
|
|
115
|
+
start: vi.fn(async () => ({ sessionId: 'new-sess', async send() { }, async stop() { } })),
|
|
116
|
+
resume: vi.fn(async (_p, sessionId) => ({ sessionId, async send() { }, async stop() { } })),
|
|
117
|
+
stop: vi.fn(async (_g, _c) => { }),
|
|
118
|
+
stopAll: vi.fn(async () => { }),
|
|
119
|
+
// A minimal read-only ModeContext for listResumable (cwd/config/logger only).
|
|
120
|
+
buildListContext: vi.fn((_mode, cwd) => ({
|
|
121
|
+
guildId: '',
|
|
122
|
+
channelId: '',
|
|
123
|
+
cwd,
|
|
124
|
+
ownerId: '',
|
|
125
|
+
permMode: 'default',
|
|
126
|
+
emit: () => { },
|
|
127
|
+
requestPermission: async () => ({ behavior: 'deny' }),
|
|
128
|
+
config: {},
|
|
129
|
+
logger,
|
|
130
|
+
audit: () => { },
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
return { orchestrator: calls, calls };
|
|
134
|
+
}
|
|
135
|
+
function slash(over) {
|
|
136
|
+
const replies = [];
|
|
137
|
+
const acks = [];
|
|
138
|
+
let acked = false;
|
|
139
|
+
const roles = over.roles ?? [EXEC_ROLE];
|
|
140
|
+
const interaction = {
|
|
141
|
+
kind: 'slash',
|
|
142
|
+
guildId: over.guildId ?? 'g1',
|
|
143
|
+
channelId: over.channelId ?? 'c1',
|
|
144
|
+
user: over.user ?? { id: 'u1' },
|
|
145
|
+
member: { roles: { cache: { map: (fn) => roles.map((id) => fn({ id })) } } },
|
|
146
|
+
...(over.hasAdminPermission !== undefined ? { hasAdminPermission: over.hasAdminPermission } : {}),
|
|
147
|
+
commandName: over.commandName ?? 'agent',
|
|
148
|
+
subcommand: over.subcommand ?? null,
|
|
149
|
+
getString: () => over.getStringValue ?? null,
|
|
150
|
+
reply: async (o) => {
|
|
151
|
+
acked = true;
|
|
152
|
+
acks.push({ kind: 'reply', payload: o });
|
|
153
|
+
replies.push(o);
|
|
154
|
+
},
|
|
155
|
+
deferReply: async (o) => {
|
|
156
|
+
acked = true;
|
|
157
|
+
acks.push({ kind: 'deferReply', payload: o });
|
|
158
|
+
},
|
|
159
|
+
editReply: async (o) => {
|
|
160
|
+
acked = true;
|
|
161
|
+
acks.push({ kind: 'editReply', payload: o });
|
|
162
|
+
replies.push(o);
|
|
163
|
+
},
|
|
164
|
+
followUp: async (o) => {
|
|
165
|
+
acked = true;
|
|
166
|
+
acks.push({ kind: 'followUp', payload: o });
|
|
167
|
+
replies.push(o);
|
|
168
|
+
},
|
|
169
|
+
get acknowledged() {
|
|
170
|
+
return acked;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return { interaction, replies, acks };
|
|
174
|
+
}
|
|
175
|
+
function component(over) {
|
|
176
|
+
const replies = [];
|
|
177
|
+
const acks = [];
|
|
178
|
+
let acked = false;
|
|
179
|
+
const roles = over.roles ?? [EXEC_ROLE];
|
|
180
|
+
const interaction = {
|
|
181
|
+
kind: 'component',
|
|
182
|
+
guildId: over.guildId ?? 'g1',
|
|
183
|
+
channelId: over.channelId ?? 'c1',
|
|
184
|
+
user: over.user ?? { id: 'u1' },
|
|
185
|
+
member: { roles: { cache: { map: (fn) => roles.map((id) => fn({ id })) } } },
|
|
186
|
+
...(over.hasAdminPermission !== undefined ? { hasAdminPermission: over.hasAdminPermission } : {}),
|
|
187
|
+
customId: over.customId ?? 'x',
|
|
188
|
+
...(over.value !== undefined ? { value: over.value } : {}),
|
|
189
|
+
...(over.values !== undefined ? { values: over.values } : {}),
|
|
190
|
+
reply: async (o) => {
|
|
191
|
+
acked = true;
|
|
192
|
+
acks.push({ kind: 'reply', payload: o });
|
|
193
|
+
replies.push(o);
|
|
194
|
+
},
|
|
195
|
+
deferReply: async (o) => {
|
|
196
|
+
acked = true;
|
|
197
|
+
acks.push({ kind: 'deferReply', payload: o });
|
|
198
|
+
},
|
|
199
|
+
editReply: async (o) => {
|
|
200
|
+
acked = true;
|
|
201
|
+
acks.push({ kind: 'editReply', payload: o });
|
|
202
|
+
replies.push(o);
|
|
203
|
+
},
|
|
204
|
+
followUp: async (o) => {
|
|
205
|
+
acked = true;
|
|
206
|
+
acks.push({ kind: 'followUp', payload: o });
|
|
207
|
+
replies.push(o);
|
|
208
|
+
},
|
|
209
|
+
deferUpdate: async () => {
|
|
210
|
+
acked = true;
|
|
211
|
+
acks.push({ kind: 'deferUpdate' });
|
|
212
|
+
},
|
|
213
|
+
showModal: async (modal) => {
|
|
214
|
+
// showModal IS the ack — record it (and mark acknowledged) so a test can assert
|
|
215
|
+
// it fired without a preceding defer.
|
|
216
|
+
acked = true;
|
|
217
|
+
acks.push({ kind: 'showModal', modal });
|
|
218
|
+
},
|
|
219
|
+
get acknowledged() {
|
|
220
|
+
return acked;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
return { interaction, replies, acks };
|
|
224
|
+
}
|
|
225
|
+
// A scripted ModalSubmit interaction: `fields` maps field custom id → submitted value.
|
|
226
|
+
function modalSubmit(over) {
|
|
227
|
+
const replies = [];
|
|
228
|
+
const acks = [];
|
|
229
|
+
let acked = false;
|
|
230
|
+
const roles = over.roles ?? [EXEC_ROLE];
|
|
231
|
+
const fields = over.fields ?? {};
|
|
232
|
+
const interaction = {
|
|
233
|
+
kind: 'modalSubmit',
|
|
234
|
+
guildId: over.guildId ?? 'g1',
|
|
235
|
+
channelId: over.channelId ?? 'c1',
|
|
236
|
+
user: over.user ?? { id: 'u1' },
|
|
237
|
+
member: { roles: { cache: { map: (fn) => roles.map((id) => fn({ id })) } } },
|
|
238
|
+
...(over.hasAdminPermission !== undefined ? { hasAdminPermission: over.hasAdminPermission } : {}),
|
|
239
|
+
customId: over.customId ?? 'config.codexHome.modal',
|
|
240
|
+
getField: (id) => fields[id] ?? '',
|
|
241
|
+
reply: async (o) => {
|
|
242
|
+
acked = true;
|
|
243
|
+
acks.push({ kind: 'reply', payload: o });
|
|
244
|
+
replies.push(o);
|
|
245
|
+
},
|
|
246
|
+
deferReply: async (o) => {
|
|
247
|
+
acked = true;
|
|
248
|
+
acks.push({ kind: 'deferReply', payload: o });
|
|
249
|
+
},
|
|
250
|
+
editReply: async (o) => {
|
|
251
|
+
acked = true;
|
|
252
|
+
acks.push({ kind: 'editReply', payload: o });
|
|
253
|
+
replies.push(o);
|
|
254
|
+
},
|
|
255
|
+
followUp: async (o) => {
|
|
256
|
+
acked = true;
|
|
257
|
+
acks.push({ kind: 'followUp', payload: o });
|
|
258
|
+
replies.push(o);
|
|
259
|
+
},
|
|
260
|
+
get acknowledged() {
|
|
261
|
+
return acked;
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
return { interaction, replies, acks };
|
|
265
|
+
}
|
|
266
|
+
let home;
|
|
267
|
+
let store;
|
|
268
|
+
let stateStore;
|
|
269
|
+
let channelRegistry;
|
|
270
|
+
let configResolver;
|
|
271
|
+
let permissionResolver;
|
|
272
|
+
let modeRegistry;
|
|
273
|
+
let authorizer;
|
|
274
|
+
function buildRouter(deps) {
|
|
275
|
+
return new InteractionRouter({
|
|
276
|
+
authorizer,
|
|
277
|
+
orchestrator: deps.orchestrator,
|
|
278
|
+
channelRegistry,
|
|
279
|
+
configStore: store,
|
|
280
|
+
configResolver,
|
|
281
|
+
permissionResolver,
|
|
282
|
+
modeRegistry,
|
|
283
|
+
wiring: deps.wiring,
|
|
284
|
+
logger: deps.logger ?? logger,
|
|
285
|
+
modelsFor: () => [
|
|
286
|
+
{ value: 'opus', label: 'opus' },
|
|
287
|
+
{ value: 'sonnet', label: 'sonnet' },
|
|
288
|
+
],
|
|
289
|
+
...(deps.browseRoots ? { browseRoots: deps.browseRoots } : {}),
|
|
290
|
+
...(deps.resolveGuildProvisioner ? { resolveGuildProvisioner: deps.resolveGuildProvisioner } : {}),
|
|
291
|
+
...(deps.resolveChannel ? { resolveChannel: deps.resolveChannel } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// A fake provisioner for /init + session-channel tests: records creates + deletes and
|
|
295
|
+
// resolves reuse by an in-memory channel map (mirrors guildChannels.test.ts's fake).
|
|
296
|
+
class FakeProvisioner {
|
|
297
|
+
guildId;
|
|
298
|
+
channels = new Map(); // id → name
|
|
299
|
+
createdNames = [];
|
|
300
|
+
deleted = [];
|
|
301
|
+
seq = 0;
|
|
302
|
+
manageChannels = true;
|
|
303
|
+
constructor(guildId = 'g1') {
|
|
304
|
+
this.guildId = guildId;
|
|
305
|
+
}
|
|
306
|
+
canManageChannels() {
|
|
307
|
+
return this.manageChannels;
|
|
308
|
+
}
|
|
309
|
+
channelExists(id) {
|
|
310
|
+
return this.channels.has(id);
|
|
311
|
+
}
|
|
312
|
+
nextId() {
|
|
313
|
+
this.seq += 1;
|
|
314
|
+
return `chan-${this.seq}`;
|
|
315
|
+
}
|
|
316
|
+
async ensureCategory(name, existingId) {
|
|
317
|
+
if (existingId && this.channels.has(existingId))
|
|
318
|
+
return { id: existingId, name: this.channels.get(existingId) };
|
|
319
|
+
const id = this.nextId();
|
|
320
|
+
this.channels.set(id, name);
|
|
321
|
+
this.createdNames.push(name);
|
|
322
|
+
return { id, name };
|
|
323
|
+
}
|
|
324
|
+
async ensureTextChannel(name, _parentId, existingId) {
|
|
325
|
+
if (existingId && this.channels.has(existingId))
|
|
326
|
+
return { id: existingId, name: this.channels.get(existingId) };
|
|
327
|
+
const id = this.nextId();
|
|
328
|
+
this.channels.set(id, name);
|
|
329
|
+
this.createdNames.push(name);
|
|
330
|
+
return { id, name };
|
|
331
|
+
}
|
|
332
|
+
async createTextChannel(name, _parentId) {
|
|
333
|
+
const id = this.nextId();
|
|
334
|
+
this.channels.set(id, name);
|
|
335
|
+
this.createdNames.push(name);
|
|
336
|
+
return { id, name };
|
|
337
|
+
}
|
|
338
|
+
async deleteChannel(id) {
|
|
339
|
+
this.channels.delete(id);
|
|
340
|
+
this.deleted.push(id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// A fake MessageChannel that records every posted message (for the intro/status post).
|
|
344
|
+
function fakeMessageChannel() {
|
|
345
|
+
const posts = [];
|
|
346
|
+
const channel = {
|
|
347
|
+
send: async (msg) => {
|
|
348
|
+
posts.push(msg);
|
|
349
|
+
return { id: 'posted-msg', async edit() { } };
|
|
350
|
+
},
|
|
351
|
+
startThread: async () => ({ id: 'thread', async send() { return { id: 'm', async edit() { } }; } }),
|
|
352
|
+
};
|
|
353
|
+
return { channel, posts };
|
|
354
|
+
}
|
|
355
|
+
beforeEach(() => {
|
|
356
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-ir-'));
|
|
357
|
+
writeConfig(home);
|
|
358
|
+
store = new ConfigStore(home);
|
|
359
|
+
stateStore = new StateStore(home);
|
|
360
|
+
channelRegistry = new ChannelRegistry(stateStore);
|
|
361
|
+
configResolver = new ConfigResolver(store, channelRegistry);
|
|
362
|
+
permissionResolver = new PermissionResolver(store, configResolver);
|
|
363
|
+
modeRegistry = new ModeRegistry();
|
|
364
|
+
modeRegistry.register(new StubMode('claude', CLAUDE_CAPS));
|
|
365
|
+
modeRegistry.register(new StubMode('codex', { ...CLAUDE_CAPS, usagePanel: false }));
|
|
366
|
+
authorizer = new Authorizer(store, channelRegistry);
|
|
367
|
+
});
|
|
368
|
+
afterEach(() => {
|
|
369
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
370
|
+
});
|
|
371
|
+
describe('InteractionRouter slash commands', () => {
|
|
372
|
+
it('/stop-all by a non-admin (execute) is denied — stopAll not called', async () => {
|
|
373
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
374
|
+
const { wiring } = fakeWiring();
|
|
375
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
376
|
+
const { interaction, replies, acks } = slash({ commandName: 'stop-all', roles: [EXEC_ROLE] });
|
|
377
|
+
await router.handle(interaction);
|
|
378
|
+
expect(calls.stopAll).not.toHaveBeenCalled();
|
|
379
|
+
// Deferred ephemerally FIRST, then the denial edits that ephemeral reply.
|
|
380
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
381
|
+
expect(acks[0].payload?.ephemeral).toBe(true);
|
|
382
|
+
expect(replies[0].content).toContain('권한이 없습니다');
|
|
383
|
+
});
|
|
384
|
+
it('/stop-all by an admin stops all sessions', async () => {
|
|
385
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
386
|
+
const { wiring } = fakeWiring();
|
|
387
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
388
|
+
const { interaction } = slash({ commandName: 'stop-all', roles: [ADMIN_ROLE] });
|
|
389
|
+
await router.handle(interaction);
|
|
390
|
+
expect(calls.stopAll).toHaveBeenCalledOnce();
|
|
391
|
+
});
|
|
392
|
+
it('/agent start launches the wizard WITH the folder-picker components (not just text)', async () => {
|
|
393
|
+
const { orchestrator } = fakeOrchestrator();
|
|
394
|
+
const { wiring } = fakeWiring();
|
|
395
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
396
|
+
const { interaction, replies } = slash({ commandName: 'agent', subcommand: 'start' });
|
|
397
|
+
await router.handle(interaction);
|
|
398
|
+
// The launched reply carries the step embed AND the folder-picker component rows —
|
|
399
|
+
// this is the LIVE bug: it used to send only the "마법사 열었어요" text with no
|
|
400
|
+
// components, so the user had nothing to click.
|
|
401
|
+
expect(replies[0].content).toContain('마법사');
|
|
402
|
+
expect(replies[0].embeds && replies[0].embeds.length).toBeGreaterThan(0);
|
|
403
|
+
const rows = (replies[0].components ?? []);
|
|
404
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
405
|
+
const flat = rows.flatMap((r) => r.components);
|
|
406
|
+
// The folder select (dir:into) + ⬆ up / ✅ start buttons are all present.
|
|
407
|
+
expect(flat.some((c) => c.type === 'select' && c.customId === 'dir:into')).toBe(true);
|
|
408
|
+
expect(flat.some((c) => c.type === 'button' && c.customId === 'dir:up')).toBe(true);
|
|
409
|
+
expect(flat.some((c) => c.type === 'button' && c.customId === 'dir:here')).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
it('/agent start: a folder-select advances the wizard and re-renders the NEXT step with components', async () => {
|
|
412
|
+
const { orchestrator } = fakeOrchestrator();
|
|
413
|
+
const { wiring } = fakeWiring();
|
|
414
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
415
|
+
const { interaction: start } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
416
|
+
await router.handle(start);
|
|
417
|
+
// Selecting the current folder (✅ 이 폴더로 시작) advances folder → backend; the
|
|
418
|
+
// component is deferUpdate'd and the router edits the message with the backend step,
|
|
419
|
+
// which again carries components (the backend select + cancel button).
|
|
420
|
+
const { interaction: pick, replies } = component({ customId: 'dir:here', user: { id: 'u1' } });
|
|
421
|
+
await router.handle(pick);
|
|
422
|
+
const edited = replies.find((r) => r.components && r.components.length > 0);
|
|
423
|
+
expect(edited).toBeTruthy();
|
|
424
|
+
const rows = (edited.components ?? []);
|
|
425
|
+
const flat = rows.flatMap((r) => r.components);
|
|
426
|
+
expect(flat.some((c) => c.type === 'select' && c.customId === 'backend')).toBe(true);
|
|
427
|
+
});
|
|
428
|
+
it('/agent start: EVERY step advances via its BUTTON and re-renders the next step with components', async () => {
|
|
429
|
+
const { orchestrator } = fakeOrchestrator();
|
|
430
|
+
const { wiring } = fakeWiring();
|
|
431
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
432
|
+
const { interaction: start } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
433
|
+
await router.handle(start);
|
|
434
|
+
// The choice steps advance on their confirm BUTTON (backend.next / model.next /
|
|
435
|
+
// effort.next), not the select's change. Each transition re-renders the next step
|
|
436
|
+
// with its select/buttons on the edited message. The final perm step carries the
|
|
437
|
+
// ✅ 시작 (perm.start) button.
|
|
438
|
+
const steps = [
|
|
439
|
+
{ customId: 'dir:here', expectId: 'backend', expectType: 'select' },
|
|
440
|
+
{ customId: 'backend.next', expectId: 'model', expectType: 'select' },
|
|
441
|
+
{ customId: 'model.next', expectId: 'effort', expectType: 'select' },
|
|
442
|
+
{ customId: 'effort.next', expectId: 'perm.start', expectType: 'button' },
|
|
443
|
+
];
|
|
444
|
+
for (const step of steps) {
|
|
445
|
+
const { interaction, replies } = component({ customId: step.customId, user: { id: 'u1' } });
|
|
446
|
+
await router.handle(interaction);
|
|
447
|
+
const edited = replies.find((r) => r.components && r.components.length > 0);
|
|
448
|
+
expect(edited, `step after ${step.customId} must carry components`).toBeTruthy();
|
|
449
|
+
const flat = (edited.components ?? [])
|
|
450
|
+
.flatMap((r) => r.components);
|
|
451
|
+
expect(flat.some((c) => c.type === step.expectType && c.customId === step.expectId)).toBe(true);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
it('/agent start: a select-change updates pending state + re-renders WITHOUT advancing', async () => {
|
|
455
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
456
|
+
const { wiring } = fakeWiring();
|
|
457
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
458
|
+
const { interaction: start } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
459
|
+
await router.handle(start);
|
|
460
|
+
await router.handle(component({ customId: 'dir:here', user: { id: 'u1' } }).interaction);
|
|
461
|
+
// Changing the backend select (to codex) must NOT advance to the model step — it
|
|
462
|
+
// re-renders the backend step with codex pre-selected; the session is not started.
|
|
463
|
+
const { interaction: change, replies } = component({ customId: 'backend', value: 'codex', user: { id: 'u1' } });
|
|
464
|
+
await router.handle(change);
|
|
465
|
+
const edited = replies.find((r) => r.components && r.components.length > 0);
|
|
466
|
+
const flat = (edited?.components ?? []).flatMap((r) => r.components);
|
|
467
|
+
// Still on the backend step (backend select + backend.next button), NOT the model step.
|
|
468
|
+
expect(flat.some((c) => c.customId === 'backend.next')).toBe(true);
|
|
469
|
+
expect(flat.some((c) => c.customId === 'model')).toBe(false);
|
|
470
|
+
const backendSelect = flat.find((c) => c.customId === 'backend');
|
|
471
|
+
expect(backendSelect?.options?.find((o) => o.value === 'codex')?.default).toBe(true);
|
|
472
|
+
expect(calls.start).not.toHaveBeenCalled();
|
|
473
|
+
});
|
|
474
|
+
it('/mode backend switches backend with the fresh-context warning', async () => {
|
|
475
|
+
channelRegistry.set(binding(home));
|
|
476
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
477
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
478
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
479
|
+
const { interaction, acks } = slash({
|
|
480
|
+
commandName: 'mode',
|
|
481
|
+
subcommand: 'backend',
|
|
482
|
+
getStringValue: 'codex',
|
|
483
|
+
});
|
|
484
|
+
await router.handle(interaction);
|
|
485
|
+
expect(calls.stop).toHaveBeenCalledWith('g1', 'c1');
|
|
486
|
+
expect(wcalls.detach).toHaveBeenCalledWith('g1', 'c1');
|
|
487
|
+
expect(calls.start).toHaveBeenCalledOnce();
|
|
488
|
+
expect(wcalls.attach).toHaveBeenCalledWith('g1', 'c1', 'codex');
|
|
489
|
+
// The public fresh-context warning is a NON-ephemeral followUp (deferred reply is
|
|
490
|
+
// ephemeral); the confirmation edits the deferred reply.
|
|
491
|
+
const freshFollowUp = acks.find((a) => a.kind === 'followUp');
|
|
492
|
+
expect(freshFollowUp?.payload?.content).toContain('새 대화로 시작');
|
|
493
|
+
expect(freshFollowUp?.payload?.ephemeral).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
it('/mode backend to an UNREGISTERED backend: ephemeral notice, session NOT stopped', async () => {
|
|
496
|
+
channelRegistry.set(binding(home));
|
|
497
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
498
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
499
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
500
|
+
// 'gemini' is not registered (only claude + codex are), so it must be rejected
|
|
501
|
+
// WITHOUT tearing down the running session.
|
|
502
|
+
const { interaction, replies, acks } = slash({
|
|
503
|
+
commandName: 'mode',
|
|
504
|
+
subcommand: 'backend',
|
|
505
|
+
getStringValue: 'gemini',
|
|
506
|
+
});
|
|
507
|
+
await router.handle(interaction);
|
|
508
|
+
expect(calls.stop).not.toHaveBeenCalled();
|
|
509
|
+
expect(wcalls.detach).not.toHaveBeenCalled();
|
|
510
|
+
expect(calls.start).not.toHaveBeenCalled();
|
|
511
|
+
// Deferred ephemerally first; the notice edits that ephemeral reply.
|
|
512
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
513
|
+
expect(acks[0].payload?.ephemeral).toBe(true);
|
|
514
|
+
expect(replies[0].content).toContain('gemini');
|
|
515
|
+
});
|
|
516
|
+
it('/mode backend with NO binding is rejected instead of falling back to cwd', async () => {
|
|
517
|
+
// No channelRegistry.set → no binding.
|
|
518
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
519
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
520
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
521
|
+
const { interaction, acks } = slash({
|
|
522
|
+
commandName: 'mode',
|
|
523
|
+
subcommand: 'backend',
|
|
524
|
+
getStringValue: 'codex', // registered, but there is no session to switch
|
|
525
|
+
});
|
|
526
|
+
await router.handle(interaction);
|
|
527
|
+
expect(calls.stop).not.toHaveBeenCalled();
|
|
528
|
+
expect(wcalls.detach).not.toHaveBeenCalled();
|
|
529
|
+
expect(calls.start).not.toHaveBeenCalled();
|
|
530
|
+
// Deferred ephemerally first; the no-session notice edits that ephemeral reply.
|
|
531
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
532
|
+
expect(acks[0].payload?.ephemeral).toBe(true);
|
|
533
|
+
});
|
|
534
|
+
it('/stop stops the channel session and detaches renderers', async () => {
|
|
535
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
536
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
537
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
538
|
+
const { interaction } = slash({ commandName: 'stop' });
|
|
539
|
+
await router.handle(interaction);
|
|
540
|
+
expect(calls.stop).toHaveBeenCalledWith('g1', 'c1');
|
|
541
|
+
expect(wcalls.detach).toHaveBeenCalledWith('g1', 'c1');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
describe('InteractionRouter component interactions', () => {
|
|
545
|
+
it('perm:<id>:allow button routes to wiring.resolvePermission with the acting user id', async () => {
|
|
546
|
+
const { orchestrator } = fakeOrchestrator();
|
|
547
|
+
const { wiring, calls } = fakeWiring();
|
|
548
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
549
|
+
const { interaction } = component({ customId: 'perm:req-1:allow', user: { id: 'u1' } });
|
|
550
|
+
await router.handle(interaction);
|
|
551
|
+
// The acting user id is threaded so the handler can enforce the approver binding.
|
|
552
|
+
expect(calls.resolvePermission).toHaveBeenCalledWith('g1', 'c1', 'perm:req-1:allow', 'u1');
|
|
553
|
+
});
|
|
554
|
+
it('a foreign (non-perm, no-wizard) component is safely ignored (deferUpdate, no resolve)', async () => {
|
|
555
|
+
const { orchestrator } = fakeOrchestrator();
|
|
556
|
+
const { wiring, calls } = fakeWiring();
|
|
557
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
558
|
+
const { interaction } = component({ customId: 'dir:up' });
|
|
559
|
+
await router.handle(interaction);
|
|
560
|
+
expect(calls.resolvePermission).not.toHaveBeenCalled();
|
|
561
|
+
});
|
|
562
|
+
it('a denied user clicking a perm button does NOT resolve it', async () => {
|
|
563
|
+
const { orchestrator } = fakeOrchestrator();
|
|
564
|
+
const { wiring, calls } = fakeWiring();
|
|
565
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
566
|
+
const { interaction, replies } = component({ customId: 'perm:req-1:allow', roles: ['role-nobody'] });
|
|
567
|
+
await router.handle(interaction);
|
|
568
|
+
expect(calls.resolvePermission).not.toHaveBeenCalled();
|
|
569
|
+
expect(replies[0].content).toContain('권한이 없습니다');
|
|
570
|
+
});
|
|
571
|
+
it('a wizard component from a NON-owner is ignored; the owner advances it', async () => {
|
|
572
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
573
|
+
const { wiring } = fakeWiring();
|
|
574
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
575
|
+
// Owner 'u1' opens the wizard (browseRoots defaults to []; the browser starts at
|
|
576
|
+
// a resolvable root — 'dir:here' selects the current folder).
|
|
577
|
+
const { interaction: start } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
578
|
+
await router.handle(start);
|
|
579
|
+
const flow = [
|
|
580
|
+
{ customId: 'dir:here' },
|
|
581
|
+
{ customId: 'backend.next' },
|
|
582
|
+
{ customId: 'model.next' },
|
|
583
|
+
{ customId: 'effort.next' },
|
|
584
|
+
{ customId: 'perm.start' },
|
|
585
|
+
];
|
|
586
|
+
// A bystander 'u2' (also execute tier) runs the WHOLE flow → every input is
|
|
587
|
+
// ignored, so the session is never started.
|
|
588
|
+
for (const step of flow) {
|
|
589
|
+
const { interaction } = component({ ...step, user: { id: 'u2' } });
|
|
590
|
+
await router.handle(interaction);
|
|
591
|
+
}
|
|
592
|
+
expect(calls.start).not.toHaveBeenCalled();
|
|
593
|
+
// The owner 'u1' now advances the SAME wizard to completion → start is called.
|
|
594
|
+
for (const step of flow) {
|
|
595
|
+
const { interaction } = component({ ...step, user: { id: 'u1' } });
|
|
596
|
+
await router.handle(interaction);
|
|
597
|
+
}
|
|
598
|
+
expect(calls.start).toHaveBeenCalledOnce();
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
describe('InteractionRouter /config command', () => {
|
|
602
|
+
it('opens the panel for a Discord Administrator (empty allowlist bootstrap)', async () => {
|
|
603
|
+
const { orchestrator } = fakeOrchestrator();
|
|
604
|
+
const { wiring } = fakeWiring();
|
|
605
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
606
|
+
// A user with NO allowlisted role but the Discord Administrator permission.
|
|
607
|
+
const { interaction, replies, acks } = slash({
|
|
608
|
+
commandName: 'config',
|
|
609
|
+
roles: ['role-nobody'],
|
|
610
|
+
hasAdminPermission: true,
|
|
611
|
+
});
|
|
612
|
+
await router.handle(interaction);
|
|
613
|
+
// Deferred ephemerally first (so the 3s window is never missed), then the panel is
|
|
614
|
+
// delivered as an editReply (role tiers + Save) plus a followUp (defaults). Both
|
|
615
|
+
// together stay within Discord's 5-action-row-per-message limit.
|
|
616
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
617
|
+
expect(acks[0].payload?.ephemeral).toBe(true);
|
|
618
|
+
const edit = replies[0];
|
|
619
|
+
const follow = replies[1];
|
|
620
|
+
expect(edit.components && edit.components.length).toBeGreaterThan(0);
|
|
621
|
+
expect(edit.components.length).toBeLessThanOrEqual(5);
|
|
622
|
+
expect(follow.ephemeral).toBe(true);
|
|
623
|
+
expect(follow.components.length).toBeLessThanOrEqual(5);
|
|
624
|
+
});
|
|
625
|
+
it('opens the panel for an admin-tier user (configured allowlist)', async () => {
|
|
626
|
+
const { orchestrator } = fakeOrchestrator();
|
|
627
|
+
const { wiring } = fakeWiring();
|
|
628
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
629
|
+
const { interaction, replies } = slash({
|
|
630
|
+
commandName: 'config',
|
|
631
|
+
roles: [ADMIN_ROLE],
|
|
632
|
+
hasAdminPermission: false,
|
|
633
|
+
});
|
|
634
|
+
await router.handle(interaction);
|
|
635
|
+
// The panel (role tiers + Save) is edited into the deferred ephemeral reply.
|
|
636
|
+
expect(replies[0].components && replies[0].components.length).toBeGreaterThan(0);
|
|
637
|
+
});
|
|
638
|
+
it('denies a non-admin, non-allowlisted user; panel not opened', async () => {
|
|
639
|
+
const { orchestrator } = fakeOrchestrator();
|
|
640
|
+
const { wiring } = fakeWiring();
|
|
641
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
642
|
+
const { interaction, replies, acks } = slash({
|
|
643
|
+
commandName: 'config',
|
|
644
|
+
roles: [EXEC_ROLE], // execute tier is NOT admin
|
|
645
|
+
hasAdminPermission: false,
|
|
646
|
+
});
|
|
647
|
+
await router.handle(interaction);
|
|
648
|
+
// Deferred ephemerally first, then the denial edits that ephemeral reply.
|
|
649
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
650
|
+
expect(acks[0].payload?.ephemeral).toBe(true);
|
|
651
|
+
expect(replies).toHaveLength(1);
|
|
652
|
+
// No panel components were sent.
|
|
653
|
+
expect(replies[0].components).toBeUndefined();
|
|
654
|
+
expect(replies[0].content).toContain('admin');
|
|
655
|
+
});
|
|
656
|
+
it('a role-select component routes to the panel and persists on Save', async () => {
|
|
657
|
+
const { orchestrator } = fakeOrchestrator();
|
|
658
|
+
const { wiring } = fakeWiring();
|
|
659
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
660
|
+
// Admin opens the panel.
|
|
661
|
+
const { interaction: open } = slash({
|
|
662
|
+
commandName: 'config',
|
|
663
|
+
user: { id: 'admin-user' },
|
|
664
|
+
hasAdminPermission: true,
|
|
665
|
+
});
|
|
666
|
+
await router.handle(open);
|
|
667
|
+
// The admin picks execute roles via the role-select, then Saves.
|
|
668
|
+
const { interaction: pick } = component({
|
|
669
|
+
customId: 'config.role.execute',
|
|
670
|
+
values: ['picked-exec'],
|
|
671
|
+
user: { id: 'admin-user' },
|
|
672
|
+
hasAdminPermission: true,
|
|
673
|
+
});
|
|
674
|
+
await router.handle(pick);
|
|
675
|
+
const { interaction: save, replies } = component({
|
|
676
|
+
customId: 'config.save',
|
|
677
|
+
user: { id: 'admin-user' },
|
|
678
|
+
hasAdminPermission: true,
|
|
679
|
+
});
|
|
680
|
+
await router.handle(save);
|
|
681
|
+
// The picked role landed in this guild's server config auth.
|
|
682
|
+
const saved = store.loadServerConfig('g1');
|
|
683
|
+
expect(saved?.auth?.executeRoleIds).toEqual(['picked-exec']);
|
|
684
|
+
// A confirmation summary was sent.
|
|
685
|
+
expect(replies[0].content).toContain('picked-exec');
|
|
686
|
+
});
|
|
687
|
+
it('a role-select from a NON-owner is ignored (does not persist)', async () => {
|
|
688
|
+
const { orchestrator } = fakeOrchestrator();
|
|
689
|
+
const { wiring } = fakeWiring();
|
|
690
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
691
|
+
const { interaction: open } = slash({
|
|
692
|
+
commandName: 'config',
|
|
693
|
+
user: { id: 'admin-user' },
|
|
694
|
+
hasAdminPermission: true,
|
|
695
|
+
});
|
|
696
|
+
await router.handle(open);
|
|
697
|
+
// A different admin (u2) tries to Save without any pick — the panel is owned by
|
|
698
|
+
// 'admin-user', so u2's interaction is acknowledged but ignored (no save).
|
|
699
|
+
const { interaction: hijack } = component({
|
|
700
|
+
customId: 'config.save',
|
|
701
|
+
user: { id: 'u2' },
|
|
702
|
+
hasAdminPermission: true,
|
|
703
|
+
});
|
|
704
|
+
await router.handle(hijack);
|
|
705
|
+
expect(store.loadServerConfig('g1')).toBeNull();
|
|
706
|
+
});
|
|
707
|
+
it('a defaults select auto-saves that one field immediately (no Save button)', async () => {
|
|
708
|
+
const { orchestrator } = fakeOrchestrator();
|
|
709
|
+
const { wiring } = fakeWiring();
|
|
710
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
711
|
+
const { interaction: open } = slash({ commandName: 'config', user: { id: 'admin-user' }, hasAdminPermission: true });
|
|
712
|
+
await router.handle(open);
|
|
713
|
+
// The owner changes the default backend → it persists at once (no Save).
|
|
714
|
+
const { interaction: pick, replies } = component({
|
|
715
|
+
customId: 'config.default.backend',
|
|
716
|
+
value: 'codex',
|
|
717
|
+
user: { id: 'admin-user' },
|
|
718
|
+
hasAdminPermission: true,
|
|
719
|
+
});
|
|
720
|
+
await router.handle(pick);
|
|
721
|
+
expect(store.loadServerConfig('g1')?.defaults?.mode).toBe('codex');
|
|
722
|
+
// A short ephemeral confirmation for just that field was sent.
|
|
723
|
+
expect(replies[0].ephemeral).toBe(true);
|
|
724
|
+
expect(replies[0].content).toContain('codex');
|
|
725
|
+
});
|
|
726
|
+
it('a locale select auto-saves the per-guild locale immediately', async () => {
|
|
727
|
+
const { orchestrator } = fakeOrchestrator();
|
|
728
|
+
const { wiring } = fakeWiring();
|
|
729
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
730
|
+
const { interaction: open } = slash({ commandName: 'config', user: { id: 'admin-user' }, hasAdminPermission: true });
|
|
731
|
+
await router.handle(open);
|
|
732
|
+
const { interaction: pick } = component({
|
|
733
|
+
customId: 'config.default.locale',
|
|
734
|
+
value: 'en',
|
|
735
|
+
user: { id: 'admin-user' },
|
|
736
|
+
hasAdminPermission: true,
|
|
737
|
+
});
|
|
738
|
+
await router.handle(pick);
|
|
739
|
+
expect(store.loadServerConfig('g1')?.locale).toBe('en');
|
|
740
|
+
});
|
|
741
|
+
it('the /config panel renders NO Codex-path button (codexHome auto-resolves)', async () => {
|
|
742
|
+
const { orchestrator } = fakeOrchestrator();
|
|
743
|
+
const { wiring } = fakeWiring();
|
|
744
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
745
|
+
const { interaction, replies } = slash({ commandName: 'config', user: { id: 'admin-user' }, hasAdminPermission: true });
|
|
746
|
+
await router.handle(interaction);
|
|
747
|
+
// The whole panel (primary role reply + defaults follow-up) carries no codexHome
|
|
748
|
+
// button, and no showModal ack ever fires from opening /config.
|
|
749
|
+
const rows = replies.flatMap((r) => (r.components ?? []));
|
|
750
|
+
const allComponents = rows.flatMap((row) => row.components);
|
|
751
|
+
expect(allComponents.some((c) => c.customId === 'config.codexHome.open')).toBe(false);
|
|
752
|
+
});
|
|
753
|
+
it('a stray modal submit is acknowledged generically and persists nothing', async () => {
|
|
754
|
+
const { orchestrator } = fakeOrchestrator();
|
|
755
|
+
const { wiring } = fakeWiring();
|
|
756
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
757
|
+
// The bot no longer opens any modal; a replayed/stray submit must still be acked
|
|
758
|
+
// (never "did not respond") without writing config.
|
|
759
|
+
const { interaction: submit, replies } = modalSubmit({
|
|
760
|
+
customId: 'config.codexHome.modal',
|
|
761
|
+
user: { id: 'admin-user' },
|
|
762
|
+
hasAdminPermission: true,
|
|
763
|
+
fields: { 'config.codexHome.value': '/srv/codex' },
|
|
764
|
+
});
|
|
765
|
+
await router.handle(submit);
|
|
766
|
+
expect(replies).toHaveLength(1);
|
|
767
|
+
expect(replies[0].ephemeral).toBe(true);
|
|
768
|
+
// Nothing was persisted for this guild.
|
|
769
|
+
expect(store.loadServerConfig('g1')).toBeNull();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
describe('InteractionRouter /init command', () => {
|
|
773
|
+
it('creates the category + control channel + sessions category and persists the ids', async () => {
|
|
774
|
+
const { orchestrator } = fakeOrchestrator();
|
|
775
|
+
const { wiring } = fakeWiring();
|
|
776
|
+
const prov = new FakeProvisioner('g1');
|
|
777
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => prov });
|
|
778
|
+
const { interaction, replies } = slash({ commandName: 'init', hasAdminPermission: true, roles: ['role-nobody'] });
|
|
779
|
+
await router.handle(interaction);
|
|
780
|
+
// Three channels created; ids persisted to servers/g1.json.
|
|
781
|
+
expect(prov.createdNames).toHaveLength(3);
|
|
782
|
+
const saved = store.loadServerConfig('g1');
|
|
783
|
+
expect(saved?.channels?.controlChannelId).toBeTruthy();
|
|
784
|
+
expect(saved?.channels?.sessionsCategoryId).toBeTruthy();
|
|
785
|
+
// The reply links the control channel.
|
|
786
|
+
expect(replies[0].content).toContain(`<#${saved.channels.controlChannelId}>`);
|
|
787
|
+
});
|
|
788
|
+
it('is idempotent: a second /init reuses the stored channels (no duplicates)', async () => {
|
|
789
|
+
const { orchestrator } = fakeOrchestrator();
|
|
790
|
+
const { wiring } = fakeWiring();
|
|
791
|
+
const prov = new FakeProvisioner('g1');
|
|
792
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => prov });
|
|
793
|
+
const { interaction: first } = slash({ commandName: 'init', hasAdminPermission: true });
|
|
794
|
+
await router.handle(first);
|
|
795
|
+
const afterFirst = store.loadServerConfig('g1')?.channels;
|
|
796
|
+
expect(prov.createdNames).toHaveLength(3);
|
|
797
|
+
const { interaction: second } = slash({ commandName: 'init', hasAdminPermission: true });
|
|
798
|
+
await router.handle(second);
|
|
799
|
+
// No new creates; the stored ids are unchanged.
|
|
800
|
+
expect(prov.createdNames).toHaveLength(3);
|
|
801
|
+
expect(store.loadServerConfig('g1')?.channels).toEqual(afterFirst);
|
|
802
|
+
});
|
|
803
|
+
it('denies /init for a non-admin, non-allowlisted user (no channels created)', async () => {
|
|
804
|
+
const { orchestrator } = fakeOrchestrator();
|
|
805
|
+
const { wiring } = fakeWiring();
|
|
806
|
+
const prov = new FakeProvisioner('g1');
|
|
807
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => prov });
|
|
808
|
+
const { interaction, replies } = slash({ commandName: 'init', roles: [EXEC_ROLE], hasAdminPermission: false });
|
|
809
|
+
await router.handle(interaction);
|
|
810
|
+
expect(prov.createdNames).toHaveLength(0);
|
|
811
|
+
expect(store.loadServerConfig('g1')?.channels).toBeUndefined();
|
|
812
|
+
expect(replies[0].content).toContain('admin');
|
|
813
|
+
});
|
|
814
|
+
it('reports a graceful notice when no provisioner is available', async () => {
|
|
815
|
+
const { orchestrator } = fakeOrchestrator();
|
|
816
|
+
const { wiring } = fakeWiring();
|
|
817
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => null });
|
|
818
|
+
const { interaction, replies } = slash({ commandName: 'init', hasAdminPermission: true });
|
|
819
|
+
await router.handle(interaction);
|
|
820
|
+
expect(replies[0].content).toContain('채널 관리');
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
describe('InteractionRouter /agent start creates a dedicated session channel', () => {
|
|
824
|
+
// Drive the wizard to confirm and return the recorded orchestrator/wiring calls +
|
|
825
|
+
// the new channel's posted messages.
|
|
826
|
+
async function runStartFlow(prov, sessionsCategoryId) {
|
|
827
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
828
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
829
|
+
const { channel, posts } = fakeMessageChannel();
|
|
830
|
+
const router = buildRouter({
|
|
831
|
+
orchestrator,
|
|
832
|
+
wiring,
|
|
833
|
+
resolveGuildProvisioner: async () => prov,
|
|
834
|
+
resolveChannel: async () => channel,
|
|
835
|
+
});
|
|
836
|
+
if (sessionsCategoryId) {
|
|
837
|
+
// Seed a persisted /init structure so the session channel is placed under it.
|
|
838
|
+
store.saveServerConfig({
|
|
839
|
+
version: 1,
|
|
840
|
+
guildId: 'g1',
|
|
841
|
+
channels: {
|
|
842
|
+
categoryId: 'cat',
|
|
843
|
+
controlChannelId: 'ctrl',
|
|
844
|
+
sessionsCategoryId,
|
|
845
|
+
statusChannelId: null,
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const { interaction: start } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
850
|
+
await router.handle(start);
|
|
851
|
+
// Owner drives the whole wizard to start via the confirm buttons.
|
|
852
|
+
const flow = [
|
|
853
|
+
{ customId: 'dir:here' },
|
|
854
|
+
{ customId: 'backend.next' },
|
|
855
|
+
{ customId: 'model.next' },
|
|
856
|
+
{ customId: 'effort.next' },
|
|
857
|
+
{ customId: 'perm.start' },
|
|
858
|
+
];
|
|
859
|
+
const compReplies = [];
|
|
860
|
+
for (const step of flow) {
|
|
861
|
+
const { interaction, replies } = component({ ...step, user: { id: 'u1' } });
|
|
862
|
+
await router.handle(interaction);
|
|
863
|
+
compReplies.push(...replies);
|
|
864
|
+
}
|
|
865
|
+
return { calls, wcalls, posts, compReplies };
|
|
866
|
+
}
|
|
867
|
+
it('creates a NEW channel, binds the session + wires renderers to the new channel id', async () => {
|
|
868
|
+
const prov = new FakeProvisioner('g1');
|
|
869
|
+
const { calls, wcalls, posts, compReplies } = await runStartFlow(prov);
|
|
870
|
+
// A dedicated session channel was created (proj-*).
|
|
871
|
+
expect(prov.createdNames).toHaveLength(1);
|
|
872
|
+
expect(prov.createdNames[0]).toMatch(/^proj-/);
|
|
873
|
+
const newChannelId = [...prov.channels.keys()][0];
|
|
874
|
+
// The session started bound to the NEW channel (not the command channel 'c1').
|
|
875
|
+
expect(calls.start).toHaveBeenCalledOnce();
|
|
876
|
+
expect(calls.start).toHaveBeenCalledWith(expect.objectContaining({ channelId: newChannelId }));
|
|
877
|
+
expect(calls.start).not.toHaveBeenCalledWith(expect.objectContaining({ channelId: 'c1' }));
|
|
878
|
+
// Renderers/eventBus subscription keys off the NEW channel id (wiring.attach).
|
|
879
|
+
expect(wcalls.attach).toHaveBeenCalledWith('g1', newChannelId, 'claude');
|
|
880
|
+
// The status embed + intro were posted INTO the new channel.
|
|
881
|
+
expect(posts.length).toBeGreaterThanOrEqual(1);
|
|
882
|
+
expect(posts[0].embeds && posts[0].embeds.length).toBeGreaterThan(0);
|
|
883
|
+
// The driver was told where the session channel is.
|
|
884
|
+
expect(compReplies.some((r) => r.content?.includes(`<#${newChannelId}>`))).toBe(true);
|
|
885
|
+
});
|
|
886
|
+
it('places the new session channel under the /init sessions category when present', async () => {
|
|
887
|
+
const prov = new FakeProvisioner('g1');
|
|
888
|
+
// Seed the sessions category id into the fake so createTextChannel records its parent.
|
|
889
|
+
prov.channels.set('sessions-cat', 'Agent - Sessions');
|
|
890
|
+
const { calls } = await runStartFlow(prov, 'sessions-cat');
|
|
891
|
+
// start was called (session bound to the created channel).
|
|
892
|
+
expect(calls.start).toHaveBeenCalledOnce();
|
|
893
|
+
// The created project channel exists in the provisioner.
|
|
894
|
+
const created = prov.createdNames.find((n) => n.startsWith('proj-'));
|
|
895
|
+
expect(created).toBeTruthy();
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
describe('InteractionRouter /agent close deletes the session channel', () => {
|
|
899
|
+
it('deletes the closed channel via the provisioner (A4D behavior)', async () => {
|
|
900
|
+
channelRegistry.set(binding(home, { channelId: 'sess-1' }));
|
|
901
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
902
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
903
|
+
const prov = new FakeProvisioner('g1');
|
|
904
|
+
prov.channels.set('sess-1', 'proj-thing');
|
|
905
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => prov });
|
|
906
|
+
const { interaction } = slash({ commandName: 'agent', subcommand: 'close', channelId: 'sess-1' });
|
|
907
|
+
await router.handle(interaction);
|
|
908
|
+
expect(calls.stop).toHaveBeenCalledWith('g1', 'sess-1');
|
|
909
|
+
expect(wcalls.detach).toHaveBeenCalledWith('g1', 'sess-1');
|
|
910
|
+
expect(prov.deleted).toContain('sess-1');
|
|
911
|
+
});
|
|
912
|
+
it('never deletes the control channel on close', async () => {
|
|
913
|
+
// Persist an /init structure whose control channel is 'ctrl'.
|
|
914
|
+
store.saveServerConfig({
|
|
915
|
+
version: 1,
|
|
916
|
+
guildId: 'g1',
|
|
917
|
+
channels: { categoryId: 'cat', controlChannelId: 'ctrl', sessionsCategoryId: 'sess-cat', statusChannelId: null },
|
|
918
|
+
});
|
|
919
|
+
channelRegistry.set(binding(home, { channelId: 'ctrl' }));
|
|
920
|
+
const { orchestrator } = fakeOrchestrator();
|
|
921
|
+
const { wiring } = fakeWiring();
|
|
922
|
+
const prov = new FakeProvisioner('g1');
|
|
923
|
+
prov.channels.set('ctrl', 'agent-start');
|
|
924
|
+
const router = buildRouter({ orchestrator, wiring, resolveGuildProvisioner: async () => prov });
|
|
925
|
+
const { interaction } = slash({ commandName: 'agent', subcommand: 'close', channelId: 'ctrl' });
|
|
926
|
+
await router.handle(interaction);
|
|
927
|
+
expect(prov.deleted).not.toContain('ctrl');
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
// A logger whose calls are recorded, for the interaction-receipt + error-logging tests.
|
|
931
|
+
function fakeLogger() {
|
|
932
|
+
const info = vi.fn();
|
|
933
|
+
const error = vi.fn();
|
|
934
|
+
const logger = { debug: vi.fn(), info, warn: vi.fn(), error };
|
|
935
|
+
return { logger, info, error };
|
|
936
|
+
}
|
|
937
|
+
// These tests assert the ACK-ORDERING CONTRACT — the fix for Discord's 3-second
|
|
938
|
+
// deadline ("application did not respond"). Because CI cannot reach the real gateway,
|
|
939
|
+
// they verify that the router acknowledges (defers) BEFORE the slow handler work and
|
|
940
|
+
// that no path (including a thrown handler) leaves the interaction unacknowledged. The
|
|
941
|
+
// real fix is validated by a live retest against Discord.
|
|
942
|
+
describe('InteractionRouter acknowledgment contract (3s window fix)', () => {
|
|
943
|
+
it('/stop DEFERS before doing the slow orchestrator/wiring work', async () => {
|
|
944
|
+
channelRegistry.set(binding(home));
|
|
945
|
+
const order = [];
|
|
946
|
+
const { wiring } = fakeWiring();
|
|
947
|
+
const orchestrator = {
|
|
948
|
+
start: vi.fn(),
|
|
949
|
+
stop: vi.fn(async () => {
|
|
950
|
+
order.push('stop');
|
|
951
|
+
}),
|
|
952
|
+
stopAll: vi.fn(),
|
|
953
|
+
};
|
|
954
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
955
|
+
const { interaction, acks } = slash({ commandName: 'stop' });
|
|
956
|
+
// Record the defer relative to the orchestrator work.
|
|
957
|
+
const origDefer = interaction.deferReply;
|
|
958
|
+
interaction.deferReply = async (o) => {
|
|
959
|
+
order.push('defer');
|
|
960
|
+
return origDefer(o);
|
|
961
|
+
};
|
|
962
|
+
await router.handle(interaction);
|
|
963
|
+
// The ack (defer) fired FIRST, before orchestrator.stop.
|
|
964
|
+
expect(order).toEqual(['defer', 'stop']);
|
|
965
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
966
|
+
});
|
|
967
|
+
it('a slash handler that THROWS still produces a user-visible ack (no unhandled rejection)', async () => {
|
|
968
|
+
channelRegistry.set(binding(home));
|
|
969
|
+
const { wiring } = fakeWiring();
|
|
970
|
+
// orchestrator.stop throws → the /stop handler body throws after the defer.
|
|
971
|
+
const orchestrator = {
|
|
972
|
+
start: vi.fn(),
|
|
973
|
+
stop: vi.fn(async () => {
|
|
974
|
+
throw new Error('boom');
|
|
975
|
+
}),
|
|
976
|
+
stopAll: vi.fn(),
|
|
977
|
+
};
|
|
978
|
+
const { logger, error } = fakeLogger();
|
|
979
|
+
const router = buildRouter({ orchestrator, wiring, logger });
|
|
980
|
+
const { interaction, replies, acks } = slash({ commandName: 'stop' });
|
|
981
|
+
// Must not reject.
|
|
982
|
+
await expect(router.handle(interaction)).resolves.toBeUndefined();
|
|
983
|
+
// Deferred first, then the error was edited into the deferred reply.
|
|
984
|
+
expect(acks[0].kind).toBe('deferReply');
|
|
985
|
+
const errorReply = replies.find((r) => r.content && r.content.includes('처리하지 못했'));
|
|
986
|
+
expect(errorReply).toBeTruthy();
|
|
987
|
+
// The error was logged WITH its stack to the operator terminal.
|
|
988
|
+
expect(error).toHaveBeenCalled();
|
|
989
|
+
const errMeta = error.mock.calls.find((c) => typeof c[1] === 'object');
|
|
990
|
+
expect(errMeta && errMeta[1].stack).toContain('boom');
|
|
991
|
+
});
|
|
992
|
+
it('logs a receipt line for EVERY received interaction (slash + component)', async () => {
|
|
993
|
+
const { orchestrator } = fakeOrchestrator();
|
|
994
|
+
const { wiring } = fakeWiring();
|
|
995
|
+
const { logger, info } = fakeLogger();
|
|
996
|
+
const router = buildRouter({ orchestrator, wiring, logger });
|
|
997
|
+
const { interaction: s } = slash({ commandName: 'stop' });
|
|
998
|
+
await router.handle(s);
|
|
999
|
+
const slashReceipt = info.mock.calls.find((c) => c[0] === 'interaction received' && c[1]?.type === 'slash');
|
|
1000
|
+
expect(slashReceipt).toBeTruthy();
|
|
1001
|
+
expect((slashReceipt?.[1]).command).toBe('stop');
|
|
1002
|
+
expect((slashReceipt?.[1]).userId).toBe('u1');
|
|
1003
|
+
const { interaction: c } = component({ customId: 'perm:req-1:allow' });
|
|
1004
|
+
await router.handle(c);
|
|
1005
|
+
const compReceipt = info.mock.calls.find((call) => call[0] === 'interaction received' && call[1]?.type === 'component');
|
|
1006
|
+
expect(compReceipt).toBeTruthy();
|
|
1007
|
+
expect((compReceipt?.[1]).customId).toBe('perm:req-1:allow');
|
|
1008
|
+
});
|
|
1009
|
+
it('a perm button DEFERS (deferUpdate) before resolving the permission', async () => {
|
|
1010
|
+
const order = [];
|
|
1011
|
+
const orchestrator = fakeOrchestrator().orchestrator;
|
|
1012
|
+
const wiring = {
|
|
1013
|
+
attach: vi.fn(),
|
|
1014
|
+
detach: vi.fn(),
|
|
1015
|
+
resolvePermission: vi.fn(async () => {
|
|
1016
|
+
order.push('resolve');
|
|
1017
|
+
return { behavior: 'allow' };
|
|
1018
|
+
}),
|
|
1019
|
+
};
|
|
1020
|
+
const router = buildRouter({ orchestrator, wiring });
|
|
1021
|
+
const { interaction } = component({ customId: 'perm:req-1:allow' });
|
|
1022
|
+
const origDefer = interaction.deferUpdate;
|
|
1023
|
+
interaction.deferUpdate = async () => {
|
|
1024
|
+
order.push('defer');
|
|
1025
|
+
return origDefer();
|
|
1026
|
+
};
|
|
1027
|
+
await router.handle(interaction);
|
|
1028
|
+
// deferUpdate fired BEFORE resolvePermission (never miss the 3s window).
|
|
1029
|
+
expect(order).toEqual(['defer', 'resolve']);
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
// ---------------------------------------------------------------------------
|
|
1033
|
+
// 📁 Create folder (folder-step modal)
|
|
1034
|
+
// ---------------------------------------------------------------------------
|
|
1035
|
+
describe('InteractionRouter 📁 Create folder', () => {
|
|
1036
|
+
// Open a wizard rooted at `root` and return the router (so the folder step is live
|
|
1037
|
+
// with a browser whose cwd === root, the current browsed directory).
|
|
1038
|
+
async function openWizard(root) {
|
|
1039
|
+
const { orchestrator } = fakeOrchestrator();
|
|
1040
|
+
const { wiring } = fakeWiring();
|
|
1041
|
+
const router = buildRouter({ orchestrator, wiring, browseRoots: [root] });
|
|
1042
|
+
const { interaction } = slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } });
|
|
1043
|
+
await router.handle(interaction);
|
|
1044
|
+
return router;
|
|
1045
|
+
}
|
|
1046
|
+
it('the Create button opens a modal WITHOUT a preceding defer (showModal is the ack)', async () => {
|
|
1047
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-create-'));
|
|
1048
|
+
try {
|
|
1049
|
+
const router = await openWizard(root);
|
|
1050
|
+
const { interaction, acks } = component({ customId: 'dir:create', user: { id: 'u1' } });
|
|
1051
|
+
await router.handle(interaction);
|
|
1052
|
+
// showModal fired; no deferUpdate before it (a deferred component cannot show a modal).
|
|
1053
|
+
expect(acks.map((a) => a.kind)).toEqual(['showModal']);
|
|
1054
|
+
expect(acks[0].modal?.customId).toBe('dir:create');
|
|
1055
|
+
expect(acks[0].modal?.fields[0]?.customId).toBe('name');
|
|
1056
|
+
}
|
|
1057
|
+
finally {
|
|
1058
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
it('the modal submit mkdir\'s the folder in the CURRENT browsed dir and re-renders', async () => {
|
|
1062
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-create-'));
|
|
1063
|
+
try {
|
|
1064
|
+
const router = await openWizard(root);
|
|
1065
|
+
// Open the modal (records the current path implicitly via the live wizard).
|
|
1066
|
+
await router.handle(component({ customId: 'dir:create', user: { id: 'u1' } }).interaction);
|
|
1067
|
+
// Submit the modal with a valid name.
|
|
1068
|
+
const { interaction, replies } = modalSubmit({ customId: 'dir:create', user: { id: 'u1' }, fields: { name: 'new-folder' } });
|
|
1069
|
+
await router.handle(interaction);
|
|
1070
|
+
// The directory was created as a direct child of the browsed dir.
|
|
1071
|
+
expect(fs.existsSync(path.join(root, 'new-folder'))).toBe(true);
|
|
1072
|
+
// The reply re-rendered the folder step (component rows present) + a confirmation.
|
|
1073
|
+
const reply = replies.find((r) => r.content?.includes('new-folder'));
|
|
1074
|
+
expect(reply).toBeDefined();
|
|
1075
|
+
expect(reply?.components && reply.components.length).toBeGreaterThan(0);
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
it('rejects a traversal / separator / absolute name — no mkdir, ephemeral notice', async () => {
|
|
1082
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-create-'));
|
|
1083
|
+
try {
|
|
1084
|
+
for (const bad of ['..', 'a/b', '/etc/evil', '.', 'x\\y']) {
|
|
1085
|
+
const router = await openWizard(root);
|
|
1086
|
+
await router.handle(component({ customId: 'dir:create', user: { id: 'u1' } }).interaction);
|
|
1087
|
+
const { interaction, replies } = modalSubmit({ customId: 'dir:create', user: { id: 'u1' }, fields: { name: bad } });
|
|
1088
|
+
await router.handle(interaction);
|
|
1089
|
+
// Nothing escaped: the only entry under root is never a traversal target.
|
|
1090
|
+
expect(fs.existsSync(path.join(root, '..', 'evil'))).toBe(false);
|
|
1091
|
+
// An invalid-name notice was returned (ephemeral).
|
|
1092
|
+
expect(replies.some((r) => r.ephemeral && (r.content ?? '').length > 0)).toBe(true);
|
|
1093
|
+
}
|
|
1094
|
+
// No folder named after any bad input was created directly either.
|
|
1095
|
+
expect(fs.readdirSync(root)).toHaveLength(0);
|
|
1096
|
+
}
|
|
1097
|
+
finally {
|
|
1098
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
it('a Create from a NON-owner is ignored (deferUpdate, no modal)', async () => {
|
|
1102
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-create-'));
|
|
1103
|
+
try {
|
|
1104
|
+
const router = await openWizard(root); // owner u1
|
|
1105
|
+
const { interaction, acks } = component({ customId: 'dir:create', user: { id: 'intruder' } });
|
|
1106
|
+
await router.handle(interaction);
|
|
1107
|
+
// The stray click is acknowledged (deferUpdate) but shows NO modal.
|
|
1108
|
+
expect(acks.map((a) => a.kind)).toEqual(['deferUpdate']);
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
// Resume Session flow (both backends)
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
describe('InteractionRouter Resume Session flow', () => {
|
|
1119
|
+
// Re-register the modes with scripted resumable lists for this suite.
|
|
1120
|
+
function registerResumable(claude, codex) {
|
|
1121
|
+
modeRegistry = new ModeRegistry();
|
|
1122
|
+
modeRegistry.register(new StubMode('claude', CLAUDE_CAPS, claude));
|
|
1123
|
+
modeRegistry.register(new StubMode('codex', { ...CLAUDE_CAPS, usagePanel: false }, codex));
|
|
1124
|
+
}
|
|
1125
|
+
async function openResumeFlow(root, opts = {}) {
|
|
1126
|
+
registerResumable(opts.claude ?? [], opts.codex ?? []);
|
|
1127
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
1128
|
+
const { wiring, calls: wcalls } = fakeWiring();
|
|
1129
|
+
const { channel, posts } = fakeMessageChannel();
|
|
1130
|
+
const prov = new FakeProvisioner('g1');
|
|
1131
|
+
const router = buildRouter({
|
|
1132
|
+
orchestrator,
|
|
1133
|
+
wiring,
|
|
1134
|
+
browseRoots: [root],
|
|
1135
|
+
resolveGuildProvisioner: async () => prov,
|
|
1136
|
+
resolveChannel: async () => channel,
|
|
1137
|
+
});
|
|
1138
|
+
// Open the wizard (folder step) then press Resume Session.
|
|
1139
|
+
await router.handle(slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } }).interaction);
|
|
1140
|
+
const { interaction: resumeBtn, replies: r0 } = component({ customId: 'dir:resume', user: { id: 'u1' } });
|
|
1141
|
+
await router.handle(resumeBtn);
|
|
1142
|
+
return { router, calls, wcalls, posts, prov, firstRender: r0 };
|
|
1143
|
+
}
|
|
1144
|
+
it('picking a backend lists THAT backend\'s sessions (Claude)', async () => {
|
|
1145
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-resume-'));
|
|
1146
|
+
try {
|
|
1147
|
+
const { router } = await openResumeFlow(root, {
|
|
1148
|
+
claude: [{ sessionId: 'cl-1', cwd: root, label: 'Claude work' }],
|
|
1149
|
+
codex: [{ sessionId: 'cx-1', cwd: root, label: 'Codex work' }],
|
|
1150
|
+
});
|
|
1151
|
+
// Default backend is claude (config). Confirm the backend → list claude sessions.
|
|
1152
|
+
const { interaction, replies } = component({ customId: 'resume.backend.next', user: { id: 'u1' } });
|
|
1153
|
+
await router.handle(interaction);
|
|
1154
|
+
// The session-pick select is rendered with the claude session's label.
|
|
1155
|
+
const rows = (replies[replies.length - 1].components ?? []);
|
|
1156
|
+
const pick = rows.flatMap((r) => r.components).find((c) => c.customId === 'resume.pick');
|
|
1157
|
+
expect(pick).toBeDefined();
|
|
1158
|
+
expect(pick?.options?.map((o) => o.value)).toEqual(['cl-1']);
|
|
1159
|
+
}
|
|
1160
|
+
finally {
|
|
1161
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
it('selecting a session RESUMES it: orchestrator.resume + wiring.attach on a fresh channel (Claude)', async () => {
|
|
1165
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-resume-'));
|
|
1166
|
+
try {
|
|
1167
|
+
const { router, calls, wcalls, posts, prov } = await openResumeFlow(root, {
|
|
1168
|
+
claude: [{ sessionId: 'cl-42', cwd: root, label: 'Resume me' }],
|
|
1169
|
+
});
|
|
1170
|
+
await router.handle(component({ customId: 'resume.backend.next', user: { id: 'u1' } }).interaction);
|
|
1171
|
+
const { interaction, replies } = component({ customId: 'resume.pick', value: 'cl-42', user: { id: 'u1' } });
|
|
1172
|
+
await router.handle(interaction);
|
|
1173
|
+
// A dedicated session channel was created (proj-*).
|
|
1174
|
+
expect(prov.createdNames.some((n) => n.startsWith('proj-'))).toBe(true);
|
|
1175
|
+
const newChannelId = [...prov.channels.keys()].find((id) => prov.channels.get(id)?.startsWith('proj-'));
|
|
1176
|
+
// orchestrator.resume was called with the chosen session id, bound to the NEW channel.
|
|
1177
|
+
expect(calls.resume).toHaveBeenCalledOnce();
|
|
1178
|
+
expect(calls.resume.mock.calls[0][0]).toMatchObject({ channelId: newChannelId, mode: 'claude', cwd: root });
|
|
1179
|
+
expect(calls.resume.mock.calls[0][1]).toBe('cl-42');
|
|
1180
|
+
// Renderers were wired to the new channel; a resumed-status embed was posted.
|
|
1181
|
+
expect(wcalls.attach).toHaveBeenCalledWith('g1', newChannelId, 'claude');
|
|
1182
|
+
expect(posts.some((p) => (p.embeds ?? []).length > 0)).toBe(true);
|
|
1183
|
+
// The driver was told where the resumed session lives.
|
|
1184
|
+
expect(replies.some((r) => r.content?.includes(`<#${newChannelId}>`))).toBe(true);
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
it('works for Codex too: picking codex lists + resumes a codex session', async () => {
|
|
1191
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-resume-'));
|
|
1192
|
+
try {
|
|
1193
|
+
const { router, calls } = await openResumeFlow(root, {
|
|
1194
|
+
claude: [],
|
|
1195
|
+
codex: [{ sessionId: 'cx-7', cwd: root, label: 'Codex thread' }],
|
|
1196
|
+
});
|
|
1197
|
+
// Switch the backend select to codex, then confirm.
|
|
1198
|
+
await router.handle(component({ customId: 'resume.backend', value: 'codex', user: { id: 'u1' } }).interaction);
|
|
1199
|
+
const { interaction, replies } = component({ customId: 'resume.backend.next', user: { id: 'u1' } });
|
|
1200
|
+
await router.handle(interaction);
|
|
1201
|
+
const rows = (replies[replies.length - 1].components ?? []);
|
|
1202
|
+
const pick = rows.flatMap((r) => r.components).find((c) => c.customId === 'resume.pick');
|
|
1203
|
+
expect(pick?.options?.map((o) => o.value)).toEqual(['cx-7']);
|
|
1204
|
+
// Pick it → resume with the codex backend.
|
|
1205
|
+
await router.handle(component({ customId: 'resume.pick', value: 'cx-7', user: { id: 'u1' } }).interaction);
|
|
1206
|
+
expect(calls.resume).toHaveBeenCalledOnce();
|
|
1207
|
+
expect(calls.resume.mock.calls[0][0]).toMatchObject({ mode: 'codex' });
|
|
1208
|
+
expect(calls.resume.mock.calls[0][1]).toBe('cx-7');
|
|
1209
|
+
}
|
|
1210
|
+
finally {
|
|
1211
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
it('an empty list → ephemeral "재개할 세션이 없습니다" notice, resume NOT called', async () => {
|
|
1215
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-resume-'));
|
|
1216
|
+
try {
|
|
1217
|
+
const { router, calls } = await openResumeFlow(root, { claude: [], codex: [] });
|
|
1218
|
+
const { interaction, replies } = component({ customId: 'resume.backend.next', user: { id: 'u1' } });
|
|
1219
|
+
await router.handle(interaction);
|
|
1220
|
+
expect(replies.some((r) => (r.content ?? '').includes('재개할 세션이 없습니다'))).toBe(true);
|
|
1221
|
+
expect(calls.resume).not.toHaveBeenCalled();
|
|
1222
|
+
}
|
|
1223
|
+
finally {
|
|
1224
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
it('a Resume Session from a NON-owner is ignored (no resume flow started)', async () => {
|
|
1228
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-resume-'));
|
|
1229
|
+
try {
|
|
1230
|
+
registerResumable([{ sessionId: 'cl-1', cwd: root }], []);
|
|
1231
|
+
const { orchestrator, calls } = fakeOrchestrator();
|
|
1232
|
+
const { wiring } = fakeWiring();
|
|
1233
|
+
const router = buildRouter({ orchestrator, wiring, browseRoots: [root], resolveGuildProvisioner: async () => new FakeProvisioner('g1') });
|
|
1234
|
+
await router.handle(slash({ commandName: 'agent', subcommand: 'start', user: { id: 'u1' } }).interaction);
|
|
1235
|
+
// Intruder presses Resume → ignored; a later backend.next has no flow to advance.
|
|
1236
|
+
await router.handle(component({ customId: 'dir:resume', user: { id: 'intruder' } }).interaction);
|
|
1237
|
+
await router.handle(component({ customId: 'resume.backend.next', user: { id: 'intruder' } }).interaction);
|
|
1238
|
+
expect(calls.resume).not.toHaveBeenCalled();
|
|
1239
|
+
}
|
|
1240
|
+
finally {
|
|
1241
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
//# sourceMappingURL=interactionRouter.test.js.map
|