Skip to content

Commit 8a92774

Browse files
authored
feat(feishu): auto-select model to unblock pending runs (#25)
* feat(feishu): auto-select pending models * fix(feishu): share strict timeout parsing
1 parent 323a663 commit 8a92774

File tree

4 files changed

+270
-6
lines changed

4 files changed

+270
-6
lines changed

packages/feishu/src/__tests__/config.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,31 @@ describe('readFeishuConfig', () => {
2222
expect(config.feishuAppId).toBe('cli_test_app_id');
2323
expect(config.feishuAppSecret).toBe('test-secret');
2424
expect(config.feishuBaseUrl).toBe('https://example.invalid');
25+
expect(config.feishuModelSelectionTimeoutMs).toBe(10_000);
2526
expect('feishuPort' in config).toBe(false);
2627
expect(config.agentTimeoutMs).toBeGreaterThan(0);
2728
});
2829

30+
it('allows overriding the model auto-selection timeout', () => {
31+
const config = readFeishuConfig({
32+
...process.env,
33+
FEISHU_APP_ID: 'cli_test_app_id',
34+
FEISHU_APP_SECRET: 'test-secret',
35+
FEISHU_MODEL_SELECTION_TIMEOUT_MS: '2500',
36+
});
37+
38+
expect(config.feishuModelSelectionTimeoutMs).toBe(2_500);
39+
});
40+
41+
it('rejects dirty model auto-selection timeout input', () => {
42+
expect(() => readFeishuConfig({
43+
...process.env,
44+
FEISHU_APP_ID: 'cli_test_app_id',
45+
FEISHU_APP_SECRET: 'test-secret',
46+
FEISHU_MODEL_SELECTION_TIMEOUT_MS: '10s',
47+
})).toThrow('Invalid numeric environment variable: FEISHU_MODEL_SELECTION_TIMEOUT_MS');
48+
});
49+
2950
it('throws when required Feishu environment variables are missing', () => {
3051
expect(() => readFeishuConfig({
3152
...process.env,
@@ -47,6 +68,8 @@ describe('readFeishuConfig', () => {
4768
artifactMaxSizeBytes: 123_456,
4869
claudeBin: '/tmp/bin/claude',
4970
codexBin: '/tmp/bin/codex',
71+
opencodeBin: '/tmp/bin/opencode',
72+
feishuModelSelectionTimeoutMs: 2_345,
5073
feishuAppId: 'test-app-id',
5174
feishuAppSecret: 'test-secret',
5275
feishuBaseUrl: 'https://open.feishu.cn',
@@ -62,6 +85,8 @@ describe('readFeishuConfig', () => {
6285
expect(process.env['CLAUDE_CWD']).toBe('/tmp/feishu-workspace');
6386
expect(process.env['CLAUDE_BIN']).toBe('/tmp/bin/claude');
6487
expect(process.env['CODEX_BIN']).toBe('/tmp/bin/codex');
88+
expect(process.env['OPENCODE_BIN']).toBe('/tmp/bin/opencode');
89+
expect(process.env['FEISHU_MODEL_SELECTION_TIMEOUT_MS']).toBe('2345');
6590
});
6691
});
6792

@@ -76,6 +101,8 @@ describe('startup entry', () => {
76101
artifactMaxSizeBytes: 8 * 1024 * 1024,
77102
claudeBin: '/opt/homebrew/bin/claude',
78103
codexBin: '/opt/homebrew/bin/codex',
104+
opencodeBin: '/opt/homebrew/bin/opencode',
105+
feishuModelSelectionTimeoutMs: 10_000,
79106
feishuAppId: 'test-app-id',
80107
feishuAppSecret: 'test-secret',
81108
feishuBaseUrl: 'https://open.feishu.cn',
@@ -104,6 +131,8 @@ describe('startup entry', () => {
104131
artifactMaxSizeBytes: 8 * 1024 * 1024,
105132
claudeBin: '/opt/homebrew/bin/claude',
106133
codexBin: '/opt/homebrew/bin/codex',
134+
opencodeBin: '/opt/homebrew/bin/opencode',
135+
feishuModelSelectionTimeoutMs: 10_000,
107136
feishuAppId: 'test-app-id',
108137
feishuAppSecret: 'test-secret',
109138
feishuBaseUrl: 'https://open.feishu.cn',

packages/feishu/src/__tests__/runtime.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
} from '../runtime.js';
6060

6161
afterEach(() => {
62+
vi.useRealTimers();
6263
resetFeishuRuntimeForTests();
6364
});
6465

@@ -328,6 +329,78 @@ describe('Feishu runtime', () => {
328329
expect(buttonTexts).toEqual(['Sonnet']);
329330
});
330331

332+
it('auto-selects the first available model after timeout and resumes the pending run', async () => {
333+
vi.useFakeTimers();
334+
coreMocks.applySessionControlCommand.mockImplementation(({ conversationId, type, value }: any) => {
335+
if (type !== 'model') {
336+
throw new Error(`Unexpected control command: ${type}`);
337+
}
338+
339+
conversationModels.set(conversationId, String(value));
340+
return {
341+
kind: 'model',
342+
conversationId,
343+
value: String(value),
344+
stateChanged: true,
345+
persist: true,
346+
clearContinuation: false,
347+
requiresConfirmation: false,
348+
summaryKey: 'model.updated',
349+
};
350+
});
351+
coreMocks.evaluateConversationRunRequest.mockReturnValueOnce({
352+
kind: 'ready',
353+
conversationId: 'conv-model-timeout',
354+
backend: 'claude',
355+
});
356+
coreMocks.getAvailableBackendCapabilities.mockResolvedValueOnce([
357+
{
358+
name: 'claude',
359+
models: [
360+
{ id: 'sonnet', label: 'Sonnet' },
361+
{ id: 'opus', label: 'Opus' },
362+
],
363+
},
364+
{
365+
name: 'opencode',
366+
models: [],
367+
},
368+
]);
369+
conversationBackend.set('conv-model-timeout', 'claude');
370+
371+
const transport = {
372+
sendText: vi.fn(async () => undefined),
373+
sendCard: vi.fn(async () => undefined),
374+
updateCard: vi.fn(async () => undefined),
375+
uploadFile: vi.fn(async () => undefined),
376+
};
377+
378+
await expect(runFeishuConversation({
379+
conversationId: 'conv-model-timeout',
380+
target: {
381+
chatId: 'chat-1',
382+
},
383+
prompt: 'ship it',
384+
mode: 'code',
385+
transport,
386+
defaultCwd: process.cwd(),
387+
modelSelectionTimeoutMs: 10_000,
388+
})).resolves.toEqual({ kind: 'blocked' });
389+
390+
expect(coreMocks.runPlatformConversation).not.toHaveBeenCalled();
391+
await vi.advanceTimersByTimeAsync(9_999);
392+
expect(coreMocks.runPlatformConversation).not.toHaveBeenCalled();
393+
394+
await vi.advanceTimersByTimeAsync(1);
395+
396+
expect(conversationModels.get('conv-model-timeout')).toBe('sonnet');
397+
expect(coreMocks.runPlatformConversation).toHaveBeenCalledWith(expect.objectContaining({
398+
conversationId: 'conv-model-timeout',
399+
backend: 'claude',
400+
prompt: 'ship it',
401+
}));
402+
});
403+
331404
it('does not re-block legacy OpenCode models that can be normalized to provider/modelKey', async () => {
332405
coreMocks.evaluateConversationRunRequest.mockReturnValueOnce({
333406
kind: 'ready',

packages/feishu/src/config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ function optionalEnv(env: NodeJS.ProcessEnv, key: string): string | undefined {
1616
return env[key]?.trim() || undefined;
1717
}
1818

19+
function numberEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
20+
const raw = env[key]?.trim();
21+
if (!raw) {
22+
return fallback;
23+
}
24+
25+
const parsed = Number(raw);
26+
if (!Number.isInteger(parsed) || parsed <= 0) {
27+
throw new Error(`Invalid numeric environment variable: ${key}`);
28+
}
29+
30+
return parsed;
31+
}
32+
1933
function setOptionalEnv(key: string, value: string | undefined): void {
2034
if (value) {
2135
process.env[key] = value;
@@ -31,12 +45,19 @@ export interface FeishuConfig extends CoreConfig {
3145
feishuEncryptKey?: string;
3246
feishuVerificationToken?: string;
3347
feishuBaseUrl: string;
48+
feishuModelSelectionTimeoutMs: number;
3449
}
3550

3651
export function resolveFeishuSessionChatStateFile(stateFile: string): string {
3752
return join(dirname(stateFile), 'feishu-session-chats.json');
3853
}
3954

55+
export function resolveFeishuModelSelectionTimeoutMs(
56+
env: NodeJS.ProcessEnv = process.env,
57+
): number {
58+
return numberEnv(env, 'FEISHU_MODEL_SELECTION_TIMEOUT_MS', 10_000);
59+
}
60+
4061
export function readFeishuConfig(env: NodeJS.ProcessEnv = process.env): FeishuConfig {
4162
return {
4263
...readCoreConfig(env),
@@ -45,6 +66,7 @@ export function readFeishuConfig(env: NodeJS.ProcessEnv = process.env): FeishuCo
4566
feishuEncryptKey: optionalEnv(env, 'FEISHU_ENCRYPT_KEY'),
4667
feishuVerificationToken: optionalEnv(env, 'FEISHU_VERIFICATION_TOKEN'),
4768
feishuBaseUrl: optionalEnv(env, 'FEISHU_BASE_URL') || 'https://open.feishu.cn',
69+
feishuModelSelectionTimeoutMs: resolveFeishuModelSelectionTimeoutMs(env),
4870
};
4971
}
5072

@@ -55,4 +77,5 @@ export function applyFeishuConfigEnvironment(config: FeishuConfig): void {
5577
setOptionalEnv('FEISHU_ENCRYPT_KEY', config.feishuEncryptKey);
5678
setOptionalEnv('FEISHU_VERIFICATION_TOKEN', config.feishuVerificationToken);
5779
process.env['FEISHU_BASE_URL'] = config.feishuBaseUrl;
80+
process.env['FEISHU_MODEL_SELECTION_TIMEOUT_MS'] = String(config.feishuModelSelectionTimeoutMs);
5881
}

0 commit comments

Comments
 (0)