Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as lark from '@larksuiteoapi/node-sdk';
import { logger } from '../utils/logger.js';
import { isUserAllowed, containsDangerousCommand, isOwner, autoDetectOwner } from '../utils/security.js';
import { sessionManager } from '../session/manager.js';
import { parseFableCommand, resolveForcedModel } from '../session/model-override.js';
import { forkSession } from '../session/fork.js';
import { taskQueue } from '../session/queue.js';
import { claudeExecutor } from '../claude/executor.js';
Expand Down Expand Up @@ -1082,11 +1083,16 @@ async function handleSlashCommand(

// /status - 查看状态
if (trimmed === '/status') {
const session = sessionManager.getOrCreate(chatId, userId);
const session = sessionManager.getOrCreate(chatId, userId, agentId);
const threadForcedModel = effectiveThreadId
? sessionManager.getThreadSession(effectiveThreadId, agentId)?.forcedModel
: undefined;
const forcedModel = resolveForcedModel(threadForcedModel, session.forcedModel);
const card = buildStatusCard(
session.workingDir,
session.status,
taskQueue.pendingCountForChat(chatId),
forcedModel,
);
if (threadReplyMsgId) {
await feishuClient.replyCardInThread(threadReplyMsgId, card);
Expand Down Expand Up @@ -1152,11 +1158,13 @@ async function handleSlashCommand(
let conversationId: string | undefined;
let convCwd: string | undefined;
let workingDir: string;
let threadForcedModel: string | undefined;
if (effectiveThreadId) {
const ts = sessionManager.getThreadSession(effectiveThreadId, agentId);
conversationId = ts?.conversationId;
convCwd = ts?.conversationCwd;
workingDir = ts?.workingDir ?? session.workingDir;
threadForcedModel = ts?.forcedModel;
} else {
conversationId = session.conversationId;
convCwd = session.conversationCwd;
Expand All @@ -1176,7 +1184,8 @@ async function handleSlashCommand(
const { existsSync } = await import('node:fs');
const cwd = convCwd && existsSync(convCwd) ? convCwd : workingDir;
const sessionKey = effectiveThreadId ? `${chatId}:${userId}:${effectiveThreadId}` : `${chatId}:${userId}`;
const model = agentRegistry.get(agentId)?.model;
// /fable 强制模型也应用于压缩查询,保持与正常执行一致
const model = resolveForcedModel(threadForcedModel, session.forcedModel) ?? agentRegistry.get(agentId)?.model;

await replyFn('🗜️ 正在压缩对话上下文,请稍候(约需 30–60 秒)…');

Expand Down Expand Up @@ -1480,6 +1489,57 @@ async function handleSlashCommand(
return true;
}

// /fable [1m|off] - 强制本会话使用 claude-fable-5 模型(OWNER only)
if (trimmed === '/fable' || trimmed.startsWith('/fable ')) {
const replyFn = async (msg: string) => {
if (threadReplyMsgId) await feishuClient.replyTextInThread(threadReplyMsgId, msg);
else await feishuClient.replyText(messageId, msg);
};

if (!isOwner(userId)) {
await replyFn('⚠️ 只有管理员可以使用 /fable 命令');
return true;
}

const rawArgs = trimmed === '/fable' ? '' : trimmed.slice('/fable '.length).trim();
const parsed = parseFableCommand(rawArgs);
if (!parsed.ok) {
await replyFn(parsed.error!);
return true;
}

// 确保 chat 级 session 存在
sessionManager.getOrCreate(chatId, userId, agentId);
// 作用域:话题内且已有 thread session → 绑定该话题;否则绑定 chat 级 session
const threadSession = effectiveThreadId
? sessionManager.getThreadSession(effectiveThreadId, agentId)
: undefined;
const bindToThread = Boolean(effectiveThreadId && threadSession);

if (parsed.clear) {
// 取消强制:清掉两级作用域,确保彻底恢复默认
if (bindToThread) sessionManager.setThreadForcedModel(effectiveThreadId!, null, agentId);
sessionManager.setForcedModel(chatId, userId, null, agentId);
await replyFn('↩️ 已取消强制模型,本会话恢复使用默认模型(从下一条消息生效)。');
return true;
}

const model = parsed.model!;
if (bindToThread) {
sessionManager.setThreadForcedModel(effectiveThreadId!, model, agentId);
} else {
sessionManager.setForcedModel(chatId, userId, model, agentId);
}

const ctxLabel = parsed.context1m ? '1M 上下文' : '默认上下文';
const scopeLabel = bindToThread ? '本话题' : '本会话';
await replyFn([
`🎯 已强制${scopeLabel}使用 **${model}**(${ctxLabel}),从下一条消息生效。`,
'发送 `/fable off` 恢复默认模型;`/fable 1m` 开启 1M 上下文。',
].join('\n'));
return true;
}

// /help - 帮助
if (trimmed === '/help') {
const helpLines: string[] = [
Expand All @@ -1493,6 +1553,7 @@ async function handleSlashCommand(
'`/reset`(`/clear`)— 重置会话,清除对话历史',
'`/stop` — 中断当前正在执行的任务',
'`/config` — 查看当前 Agent 配置和人设 🔒',
'`/fable [1m|off]` — 强制本会话用 claude-fable-5 模型,`1m` 开启 1M 上下文 🔒',
'`/help` — 显示此帮助',
'',
'**── 工作区 ──**',
Expand Down Expand Up @@ -2775,6 +2836,8 @@ export async function executeClaudeTask(
// 否则回退到 owner 检查(dev agent 中非 owner 也是只读)
const agentCfg = agentRegistry.get(agentId);
const readOnly = agentCfg?.readOnly ?? !isOwner(userId);
// /fable 强制模型:thread 级优先,其次 chat 级 session,都没有则用 agent 配置模型
const forcedModel = resolveForcedModel(threadSession?.forcedModel, session.forcedModel);
// 自定义 agent 支持 persona(dev agent 没配置时 → undefined → 使用默认 buildWorkspaceSystemPrompt)
const customSystemPrompt = readPersonaFile(agentId);
const knowledgeContent = loadKnowledgeContent(agentId);
Expand All @@ -2797,7 +2860,7 @@ export async function executeClaudeTask(
prompt: effectivePrompt,
workingDir,
readOnly,
model: agentCfg?.model,
model: forcedModel ?? agentCfg?.model,
maxTurns: agentCfg?.maxTurns,
...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}),
settingSources: agentCfg?.settingSources,
Expand Down Expand Up @@ -2948,7 +3011,7 @@ export async function executeClaudeTask(
prompt: restartPromptWithImageHints,
workingDir: result.newWorkingDir,
readOnly,
model: agentCfg?.model,
model: forcedModel ?? agentCfg?.model,
maxTurns: agentCfg?.maxTurns,
...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}),
settingSources: agentCfg?.settingSources,
Expand Down Expand Up @@ -3359,12 +3422,15 @@ export async function executeDirectTask(
// AskUserQuestion 回调(与 executeClaudeTask 共享逻辑)
const onAskUserDirect = createAskUserHandler(chatId, () => threadReplyMsgId);

// /fable 强制模型:thread 级优先,其次 chat 级 session
const forcedModel = resolveForcedModel(threadSession?.forcedModel, session.forcedModel);

const result = await claudeExecutor.execute({
sessionKey,
prompt: effectivePrompt,
workingDir,
readOnly: agentCfg.readOnly,
model: agentCfg.model,
model: forcedModel ?? agentCfg.model,
maxTurns: agentCfg.maxTurns,
maxBudgetUsd: agentCfg.maxBudgetUsd,
toolAllow: agentCfg.toolAllow,
Expand Down
37 changes: 23 additions & 14 deletions src/feishu/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,29 @@ export function buildStatusCard(
workingDir: string,
sessionStatus: string,
pendingTasks: number,
forcedModel?: string,
): Record<string, unknown> {
const fields: Array<Record<string, unknown>> = [
{
is_short: true,
text: { tag: 'lark_md', content: `**工作目录:**\n${workingDir}` },
},
{
is_short: true,
text: { tag: 'lark_md', content: `**状态:**\n${sessionStatus}` },
},
{
is_short: true,
text: { tag: 'lark_md', content: `**排队任务:**\n${pendingTasks}` },
},
];
// /fable 强制模型(未设置则不展示,避免干扰)
if (forcedModel) {
fields.push({
is_short: true,
text: { tag: 'lark_md', content: `**强制模型:**\n${forcedModel}` },
});
}
return {
config: { wide_screen_mode: true },
header: {
Expand All @@ -292,20 +314,7 @@ export function buildStatusCard(
elements: [
{
tag: 'div',
fields: [
{
is_short: true,
text: { tag: 'lark_md', content: `**工作目录:**\n${workingDir}` },
},
{
is_short: true,
text: { tag: 'lark_md', content: `**状态:**\n${sessionStatus}` },
},
{
is_short: true,
text: { tag: 'lark_md', content: `**排队任务:**\n${pendingTasks}` },
},
],
fields,
},
],
};
Expand Down
114 changes: 114 additions & 0 deletions src/session/__tests__/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,117 @@ describe('SessionDatabase — user_tokens', () => {
expect(db.getUserToken('ou_user2')!.accessToken).toBe('token-2');
});
});

// ============================================================
// forced_model (/fable) — sessions + thread_sessions
// ============================================================

describe('SessionDatabase — forced_model', () => {
let db: SessionDatabase;
let tempDir: string;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'session-db-test-'));
db = new SessionDatabase(join(tempDir, 'test.db'));
});

afterEach(() => {
db.close();
rmSync(tempDir, { recursive: true, force: true });
});

it('should default forcedModel to undefined for a new session', () => {
db.upsert('chat-1:user-1', {
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/tmp',
status: 'idle',
createdAt: new Date(),
lastActiveAt: new Date(),
});
expect(db.get('chat-1:user-1')!.forcedModel).toBeUndefined();
});

it('should set and read back a session-level forced model', () => {
db.upsert('chat-1:user-1', {
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/tmp',
status: 'idle',
createdAt: new Date(),
lastActiveAt: new Date(),
});

db.updateForcedModel('chat-1:user-1', 'claude-fable-5[1m]');
expect(db.get('chat-1:user-1')!.forcedModel).toBe('claude-fable-5[1m]');

// null clears it (revert to default)
db.updateForcedModel('chat-1:user-1', null);
expect(db.get('chat-1:user-1')!.forcedModel).toBeUndefined();
});

it('should not wipe forcedModel when other columns are updated', () => {
db.upsert('chat-1:user-1', {
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/tmp',
status: 'idle',
createdAt: new Date(),
lastActiveAt: new Date(),
});
db.updateForcedModel('chat-1:user-1', 'claude-fable-5');

// A targeted update to a different column must preserve forced_model
db.updateWorkingDir('chat-1:user-1', '/projects/other');

const session = db.get('chat-1:user-1')!;
expect(session.workingDir).toBe('/projects/other');
expect(session.forcedModel).toBe('claude-fable-5');
});

it('should set and read back a thread-level forced model', () => {
const now = new Date();
db.upsertThreadSession({
threadId: 'thread-1',
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/projects/repo-a',
createdAt: now,
updatedAt: now,
});
expect(db.getThreadSession('thread-1')!.forcedModel).toBeUndefined();

db.setThreadForcedModel('thread-1', 'claude-fable-5[1m]');
expect(db.getThreadSession('thread-1')!.forcedModel).toBe('claude-fable-5[1m]');

db.setThreadForcedModel('thread-1', null);
expect(db.getThreadSession('thread-1')!.forcedModel).toBeUndefined();
});

it('should preserve thread forcedModel across an upsert conflict', () => {
const now = new Date();
db.upsertThreadSession({
threadId: 'thread-1',
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/projects/repo-a',
createdAt: now,
updatedAt: now,
});
db.setThreadForcedModel('thread-1', 'claude-fable-5');

// Re-upsert (e.g. workspace touch) must not clear forced_model — mirrors fork-field behavior
db.upsertThreadSession({
threadId: 'thread-1',
chatId: 'chat-1',
userId: 'user-1',
workingDir: '/projects/repo-a',
conversationId: 'conv-1',
conversationCwd: '/projects/repo-a',
createdAt: now,
updatedAt: new Date(),
});

expect(db.getThreadSession('thread-1')!.forcedModel).toBe('claude-fable-5');
});
});
Loading
Loading