Skip to content

Commit 55c8205

Browse files
authored
fix: require model selection for preset backends (#26)
* fix: require model selection for preset backends * fix(discord): skip setup capability checks when model exists
1 parent 8a92774 commit 55c8205

7 files changed

Lines changed: 611 additions & 53 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Preset Backend Model Setup Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Ensure preset backends still trigger model selection, and that model selection timeout falls back to a compatible model before resuming the run on Discord and Feishu.
6+
7+
**Architecture:** Extend the setup/runtime gates so "backend already known" no longer implies "setup complete". Discord keeps using the existing setup UI but can jump straight into model selection when the backend is preset, while Feishu reuses its runtime model gate and restores the timeout fallback logic for blocked runs. Both paths preserve the existing backend timeout behavior and only auto-pick models when the backend actually requires one.
8+
9+
**Tech Stack:** TypeScript, pnpm workspaces, Vitest, Discord.js, Feishu runtime helpers
10+
11+
---
12+
13+
## Chunk 1: Discord preset-backend setup path
14+
15+
### Task 1: Cover the Discord regression first
16+
17+
**Files:**
18+
- Modify: `packages/discord/src/__tests__/index.test.ts`
19+
- Modify: `packages/discord/src/__tests__/thread-setup.test.ts`
20+
21+
- [ ] **Step 1: Write the failing regression test for preset backend -> model setup**
22+
23+
Add a Discord message-create test that persists `<set-backend>` to the new thread, skips backend selection UI, and still calls setup in a way that can render model selection for the preset backend.
24+
25+
- [ ] **Step 2: Run the targeted tests to verify they fail**
26+
27+
Run: `pnpm vitest run packages/discord/src/__tests__/index.test.ts packages/discord/src/__tests__/thread-setup.test.ts`
28+
Expected: FAIL showing the preset-backend path never opens model selection / times out incorrectly.
29+
30+
- [ ] **Step 3: Write the minimal Discord implementation**
31+
32+
Modify `packages/discord/src/commands/thread-setup.ts` and `packages/discord/src/index.ts` so:
33+
- `promptThreadSetup()` accepts an optional preset backend
34+
- preset backends skip the backend card and open the model card directly when needed
35+
- model timeout falls back to a compatible existing model or the backend's first model
36+
- the caller decides setup is needed when backend is missing or backend is present but model is still required
37+
38+
- [ ] **Step 4: Run the targeted Discord tests to verify they pass**
39+
40+
Run: `pnpm vitest run packages/discord/src/__tests__/index.test.ts packages/discord/src/__tests__/thread-setup.test.ts`
41+
Expected: PASS
42+
43+
- [ ] **Step 5: Commit the Discord slice**
44+
45+
```bash
46+
git add packages/discord/src/index.ts packages/discord/src/commands/thread-setup.ts packages/discord/src/__tests__/index.test.ts packages/discord/src/__tests__/thread-setup.test.ts
47+
git commit -m "fix(discord): require model setup for preset backends"
48+
```
49+
50+
## Chunk 2: Feishu parity and runtime fallback
51+
52+
### Task 2: Restore Feishu model timeout fallback for preset backends
53+
54+
**Files:**
55+
- Modify: `packages/feishu/src/runtime.ts`
56+
- Modify: `packages/feishu/src/__tests__/runtime.test.ts`
57+
58+
- [ ] **Step 1: Write the failing Feishu regression tests**
59+
60+
Add tests for:
61+
- a preset backend with models still blocking on model selection
62+
- model selection timeout auto-picking a compatible model and resuming
63+
- manual model selection canceling the timeout path
64+
65+
- [ ] **Step 2: Run the targeted Feishu tests to verify they fail**
66+
67+
Run: `pnpm vitest run packages/feishu/src/__tests__/runtime.test.ts`
68+
Expected: FAIL because timeout fallback is currently absent and preset-backend blocked runs do not auto-resume.
69+
70+
- [ ] **Step 3: Write the minimal Feishu implementation**
71+
72+
Restore the model timeout helper in `packages/feishu/src/runtime.ts` and hook it into the blocked model-selection path so pending runs resume after timeout using the last compatible model or first backend model.
73+
74+
- [ ] **Step 4: Run the targeted Feishu tests to verify they pass**
75+
76+
Run: `pnpm vitest run packages/feishu/src/__tests__/runtime.test.ts`
77+
Expected: PASS
78+
79+
- [ ] **Step 5: Commit the Feishu slice**
80+
81+
```bash
82+
git add packages/feishu/src/runtime.ts packages/feishu/src/__tests__/runtime.test.ts
83+
git commit -m "fix(feishu): restore model fallback for preset backends"
84+
```
85+
86+
## Chunk 3: Verification and integration
87+
88+
### Task 3: Verify the cross-platform setup flow and prepare the PR
89+
90+
**Files:**
91+
- Modify: `packages/discord/src/commands/thread-setup.ts`
92+
- Modify: `packages/discord/src/index.ts`
93+
- Modify: `packages/feishu/src/runtime.ts`
94+
- Modify: tests touched above
95+
96+
- [ ] **Step 1: Run the focused package tests**
97+
98+
Run: `pnpm vitest run packages/discord/src/__tests__/index.test.ts packages/discord/src/__tests__/thread-setup.test.ts packages/feishu/src/__tests__/runtime.test.ts`
99+
Expected: PASS
100+
101+
- [ ] **Step 2: Run the broader related suite**
102+
103+
Run: `pnpm vitest run packages/discord/src/__tests__/conversation.test.ts packages/feishu/src/__tests__/backend-gate.test.ts`
104+
Expected: PASS
105+
106+
- [ ] **Step 3: Review the final diff**
107+
108+
Run: `git diff --stat` and `git diff -- packages/discord packages/feishu`
109+
Expected: Only the targeted setup/runtime and tests changed.
110+
111+
- [ ] **Step 4: Create the final commit**
112+
113+
```bash
114+
git add packages/discord/src packages/feishu/src docs/superpowers/plans/2026-03-12-preset-backend-model-setup.md
115+
git commit -m "fix: require model selection for preset backends"
116+
```
117+
118+
- [ ] **Step 5: Push and open the PR**
119+
120+
```bash
121+
git push -u origin fix/preset-backend-model-setup
122+
gh pr create --fill
123+
```

packages/discord/src/__tests__/index.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,20 @@ vi.mock('@agent-im-relay/core', () => ({
4949
directives: [],
5050
})),
5151
applyMessageControlDirectives: vi.fn(() => []),
52+
getAvailableBackendCapabilities: vi.fn(async () => [
53+
{
54+
name: 'claude',
55+
models: [
56+
{ id: 'sonnet', label: 'Sonnet' },
57+
],
58+
},
59+
{
60+
name: 'codex',
61+
models: [],
62+
},
63+
]),
5264
conversationBackend: new Map(),
65+
conversationModels: new Map(),
5366
activeConversations: new Set(),
5467
processedMessages: new Set(),
5568
pendingConversationCreation: new Set(),
@@ -116,6 +129,7 @@ import { handleDiscordMessageCreate } from '../index.js';
116129
import { handleSkillAutocomplete } from '../commands/skill.js';
117130
import {
118131
applyMessageControlDirectives,
132+
getAvailableBackendCapabilities,
119133
persistState,
120134
preprocessConversationMessage,
121135
} from '@agent-im-relay/core';
@@ -160,6 +174,7 @@ describe('handleDiscordMessageCreate', () => {
160174
}));
161175
vi.mocked(applyMessageControlDirectives).mockReset();
162176
vi.mocked(applyMessageControlDirectives).mockReturnValue([]);
177+
vi.mocked(getAvailableBackendCapabilities).mockClear();
163178
vi.mocked(persistState).mockClear();
164179
});
165180

@@ -359,4 +374,98 @@ describe('handleDiscordMessageCreate', () => {
359374
content: expect.stringContaining('Please include a prompt'),
360375
}));
361376
});
377+
378+
it('still prompts setup when the backend is preset but the thread has no model yet', async () => {
379+
const message = createBaseMessage();
380+
message.content = '<@relay-bot> <set-backend>claude</set-backend>\nship it';
381+
382+
const { conversationBackend, conversationModels } = await import('@agent-im-relay/core');
383+
conversationBackend.set('thread-model-setup-1', 'claude');
384+
conversationModels.delete('thread-model-setup-1');
385+
386+
const thread = {
387+
id: 'thread-model-setup-1',
388+
send: vi.fn(async () => undefined),
389+
} as any;
390+
const ensureMentionThread = vi.fn(async () => thread);
391+
const promptThreadSetup = vi.fn(async () => ({ backend: 'claude', model: 'sonnet', cwd: null }));
392+
const applySetupResult = vi.fn(async () => {});
393+
const runThreadConversation = vi.fn(async () => true);
394+
395+
vi.mocked(preprocessConversationMessage).mockReturnValue({
396+
prompt: 'ship it',
397+
directives: [{ type: 'backend', value: 'claude' }],
398+
});
399+
vi.mocked(applyMessageControlDirectives).mockReturnValue([
400+
{
401+
kind: 'backend',
402+
conversationId: 'thread-model-setup-1',
403+
stateChanged: false,
404+
persist: false,
405+
clearContinuation: false,
406+
requiresConfirmation: false,
407+
summaryKey: 'backend.updated',
408+
backend: 'claude',
409+
},
410+
]);
411+
412+
await handleDiscordMessageCreate(message, {
413+
botUser: { id: 'relay-bot' },
414+
hasOpenStickyThreadSession: () => false,
415+
runThreadConversation,
416+
ensureMentionThread,
417+
promptThreadSetup,
418+
applySetupResult,
419+
});
420+
421+
expect(promptThreadSetup).toHaveBeenCalledWith(thread, 'ship it', {
422+
presetBackend: 'claude',
423+
});
424+
expect(applySetupResult).toHaveBeenCalledWith('thread-model-setup-1', {
425+
backend: 'claude',
426+
model: 'sonnet',
427+
cwd: null,
428+
});
429+
expect(runThreadConversation).toHaveBeenCalledWith(thread, 'ship it', message, {
430+
mentionUserId: 'other-bot',
431+
});
432+
});
433+
434+
it('skips capability lookup when backend and model are already configured', async () => {
435+
const message = createBaseMessage();
436+
message.content = '<@relay-bot> ship it';
437+
438+
const { conversationBackend, conversationModels } = await import('@agent-im-relay/core');
439+
conversationBackend.set('thread-model-ready-1', 'claude');
440+
conversationModels.set('thread-model-ready-1', 'sonnet');
441+
442+
const thread = {
443+
id: 'thread-model-ready-1',
444+
send: vi.fn(async () => undefined),
445+
} as any;
446+
447+
const ensureMentionThread = vi.fn(async () => thread);
448+
const runThreadConversation = vi.fn(async () => true);
449+
const promptThreadSetup = vi.fn();
450+
451+
vi.mocked(preprocessConversationMessage).mockReturnValue({
452+
prompt: 'ship it',
453+
directives: [],
454+
});
455+
456+
await handleDiscordMessageCreate(message, {
457+
botUser: { id: 'relay-bot' },
458+
hasOpenStickyThreadSession: () => false,
459+
runThreadConversation,
460+
ensureMentionThread,
461+
promptThreadSetup,
462+
applySetupResult: vi.fn(),
463+
});
464+
465+
expect(getAvailableBackendCapabilities).not.toHaveBeenCalled();
466+
expect(promptThreadSetup).not.toHaveBeenCalled();
467+
expect(runThreadConversation).toHaveBeenCalledWith(thread, 'ship it', message, {
468+
mentionUserId: 'other-bot',
469+
});
470+
});
362471
});

packages/discord/src/__tests__/thread-setup.test.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const coreMocks = vi.hoisted(() => ({
1818
conversationCwd: new Map<string, string>(),
1919
conversationModels: new Map<string, string>(),
2020
persistState: vi.fn(),
21+
resolveBackendModelId: vi.fn((backend: string, model: string) => {
22+
if (backend === 'claude' && (model === 'sonnet' || model === 'opus')) {
23+
return model;
24+
}
25+
return undefined;
26+
}),
2127
}));
2228

2329
vi.mock('@agent-im-relay/core', async (importOriginal) => {
@@ -29,6 +35,7 @@ vi.mock('@agent-im-relay/core', async (importOriginal) => {
2935
conversationCwd: coreMocks.conversationCwd,
3036
conversationModels: coreMocks.conversationModels,
3137
persistState: coreMocks.persistState,
38+
resolveBackendModelId: coreMocks.resolveBackendModelId,
3239
};
3340
});
3441

@@ -260,7 +267,7 @@ describe('promptThreadSetup', () => {
260267
void resultPromise;
261268
});
262269

263-
it('cancels setup when model selection times out', async () => {
270+
it('falls back to the first model when selection times out after choosing a backend', async () => {
264271
coreMocks.getAvailableBackendCapabilities.mockResolvedValueOnce([
265272
{
266273
name: 'claude',
@@ -325,13 +332,76 @@ describe('promptThreadSetup', () => {
325332

326333
await onModelEnd?.([], 'time');
327334

328-
await expect(resultPromise).resolves.toBeNull();
335+
await expect(resultPromise).resolves.toEqual({
336+
backend: 'claude',
337+
model: 'sonnet',
338+
cwd: null,
339+
});
329340
expect(edit).toHaveBeenLastCalledWith({
330-
content: '⏰ Model 选择超时,请重新开始 setup。',
341+
content: '⏰ Model 选择超时,使用默认 Model:**sonnet**。',
331342
components: [],
332343
});
333344
});
334345

346+
it('opens model selection directly for preset backends and falls back on timeout', async () => {
347+
vi.useFakeTimers();
348+
349+
let onModelCollect: ((interaction: any) => Promise<void>) | undefined;
350+
let onModelEnd: ((interactions: any, reason: string) => Promise<void>) | undefined;
351+
const edit = vi.fn().mockResolvedValue(undefined);
352+
const createMessageComponentCollector = vi.fn(() => ({
353+
on: vi.fn((event: string, handler: (interaction: any) => Promise<void>) => {
354+
if (event === 'collect') {
355+
onModelCollect = handler;
356+
return;
357+
}
358+
359+
if (event === 'end') {
360+
onModelEnd = handler as (interactions: any, reason: string) => Promise<void>;
361+
}
362+
}),
363+
stop: vi.fn(),
364+
}));
365+
366+
const thread = {
367+
send: vi.fn(async () => ({
368+
edit,
369+
createMessageComponentCollector,
370+
})),
371+
} as any;
372+
373+
const resultPromise = promptThreadSetup(thread, 'Timeout please', {
374+
presetBackend: 'claude',
375+
});
376+
await Promise.resolve();
377+
await Promise.resolve();
378+
379+
expect(thread.send).toHaveBeenCalledWith({
380+
content: '**选择 Model**\nBackend: **claude**',
381+
components: [
382+
expect.objectContaining({
383+
toJSON: expect.any(Function),
384+
}),
385+
],
386+
});
387+
expect(onModelCollect).toBeTypeOf('function');
388+
389+
await vi.advanceTimersByTimeAsync(60_000);
390+
await onModelEnd?.([], 'time');
391+
392+
await expect(resultPromise).resolves.toEqual({
393+
backend: 'claude',
394+
model: 'sonnet',
395+
cwd: null,
396+
});
397+
expect(edit).toHaveBeenLastCalledWith({
398+
content: '⏰ Model 选择超时,使用默认 Model:**sonnet**。',
399+
components: [],
400+
});
401+
402+
vi.useRealTimers();
403+
});
404+
335405
it('persists the selected model together with the backend', async () => {
336406
await applySetupResult('thread-1', {
337407
backend: 'claude',

0 commit comments

Comments
 (0)