Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/desktop/src/main/im/feishu/__tests__/feishuAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,35 @@ describe('feishu ImChannelAdapter characterization', () => {
});
});

it('/project 已接线: projectSwitching 为 true', () => {
expect(adapter.projectSwitching).toBe(true);
});

it('/project 卡文案已提供(orchestrator projectSwitching 断言不炸)', () => {
const project = adapter.ui.cards.project!;
expect(project.title).toBeTruthy();
expect(project.hint).toBeTypeOf('function');
expect(project.emptyBody).toBeTruthy();
expect(project.btnDialogue).toBeTruthy();
expect(project.btnCancel).toBeTruthy();
expect(project.resolvedPick).toBeTypeOf('function');
expect(project.resolvedDialogue).toBeTruthy();
expect(project.resolvedCancel).toBeTruthy();
expect(project.switchFailed).toBeTypeOf('function');
expect(project.attachedUnsupported).toBeTruthy();
expect(project.dialogueName).toBeTruthy();
});

it('群/话题 lane 隔离: 两个话题各自 sessionId 独立, 切不同项目互不影响', () => {
const topicA = adapter.sessions.sessionIdFor('cli_abc', 'g/oc_chat1/t1');
const topicB = adapter.sessions.sessionIdFor('cli_abc', 'g/oc_chat1/t2');
const dm = adapter.sessions.sessionIdFor('cli_abc', 'ou_owner');
expect(topicA).toBe('feishu_cli_abc_g-oc_chat1-t1');
expect(topicB).toBe('feishu_cli_abc_g-oc_chat1-t2');
expect(topicA).not.toBe(topicB);
expect(topicA).not.toBe(dm);
});

it('默认 title 为 [飞书·DM] {openId 后 6 位}; ack emoji 为 SMUG', () => {
expect(adapter.sessions.defaultTitle('ou_1234567890')).toBe('[飞书·DM] 567890');
expect(adapter.processingEmoji).toBe('SMUG');
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/im/feishu/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ export function buildFeishuAdapter(
},
processingEmoji: REACTION_PROCESSING,
buildVendorOptions: (userId) => ({ feishuChatId: userId, source: 'feishu' }),
// /project: 把当前 lane 的会话切到 desktop 项目目录(lane 级独立, 话题互不影响)。
projectSwitching: true,
Comment thread
lengjingxu marked this conversation as resolved.

// 群轮次(speaker 存在)统一挂强确认策略 — 群历史前缀携带成员可控文本,
// 注入可借 owner 轮次的宽松档执行危险操作; 确认卡经 deliverToOwnerDm
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/im/feishu/uiText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ export const ui = {
resolved: '✅ 已切到「自动审批」— 回群里继续发消息吧~',
failed: (reason: string) => `❌ 没切过去:${reason}。可以发 /permission auto 手动切换`,
},
project: {
title: '📂 换个项目目录',
hint: (currentName: string) =>
`**当前**:${currentName}\n选一个项目,接下来的消息就在那个目录里干活(会重开上下文,模型和权限设置保留)`,
emptyBody: '_desktop 端还没有活跃的项目工作区~ 先在 desktop 里打开一个项目再来_',
btnDialogue: '💬 回到对话',
btnCancel: '🚪 算了',
resolvedPick: (displayName: string) =>
`📂 已切到 **${displayName}** —— 新上下文已就位,直接发指令开干。想回普通聊天发 /project 选「回到对话」`,
resolvedDialogue: '💬 已回到对话目录,上下文重新开始~',
resolvedCancel: '🚪 没切,保持现状',
switchFailed: (reason: string) => `❌ 没切过去:${reason}(目录可能已被移动或删除)`,
attachedUnsupported:
'🎮 你正在 /ctr 接管一个 desktop 任务——接管期间消息直接进那个任务,不需要 /project。想切项目先 `/exctr` 退出接管',
// 群卡认不出自己在哪条话题(应用重启过, 卡片和话题的对应关系只存在内存里)。
// 跟 /ctr 同款 fail-closed: 宁可不切, 也不把项目切到私聊身份的会话行上。
staleGroupCard:
'❌ 没切过去:这张卡片是应用重启前发的,已经认不出它属于哪条话题了。\n' +
'请在你想切项目的那条话题里重新发一次 `/project`(切换只跟话题走,不会串到别处)',
dialogueName: '对话(托管目录)',
},
control: {
title: '🎮 挑个工作区上号',
emptyBody: '_暂时还没有可接管的工作区~ 在 desktop 端打开/创建一个任务再来_',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ const CONTROL_NEW: Partial<IMCardActionEvent> = {
payload: { botAppId: 'cli_abc', workingDir: '/tmp/proj', displayName: 'proj' },
};

const PROJECT_PICK: Partial<IMCardActionEvent> = {
messageId: 'om_picker',
buttonId: 'project:pick',
chatId: 'oc_chat1',
payload: { botAppId: 'cli_abc', workingDir: '/tmp/proj', displayName: 'proj' },
};

beforeEach(() => {
vi.clearAllMocks();
activateImAccountBoundary();
Expand Down Expand Up @@ -240,3 +247,48 @@ describe('飞书群卡片 lane 丢失时的 /ctr fail-closed', () => {
);
});
});

describe('飞书群卡片 lane 丢失时的 /project fail-closed', () => {
it('project:pick: 群卡 + senderId 是私聊 open_id ⇒ 不切目录, 提示重发 /project', async () => {
const im = makeIm();

await press(im, { ...PROJECT_PICK, senderId: 'ou_owner' });

// 关键断言: 绝不能切 —— 否则项目切到 owner 私聊身份的会话行上。
const { switchSessionWorkingDir } = await import('../sessionRepo');
expect(switchSessionWorkingDir).not.toHaveBeenCalled();
expect(im.updateInteractiveCard).toHaveBeenCalledWith(
'om_picker',
expect.objectContaining({ body: feishuUi.cards.project!.staleGroupCard }),
);
});

it('project:dialogue: 同样拒绝', async () => {
const im = makeIm();

await press(im, {
...PROJECT_PICK,
buttonId: 'project:dialogue',
senderId: 'ou_owner',
});

const { switchSessionWorkingDir } = await import('../sessionRepo');
expect(switchSessionWorkingDir).not.toHaveBeenCalled();
expect(im.updateInteractiveCard).toHaveBeenCalledWith(
'om_picker',
expect.objectContaining({ body: feishuUi.cards.project!.staleGroupCard }),
);
});

it('senderId 是话题 lane 时不拦(正常路径不受 guard 影响)', async () => {
const im = makeIm();

await press(im, { ...PROJECT_PICK, senderId: 'g/oc_chat1/omt_t1' });

// guard 没触发: 卡片不会被 patch 成 staleGroupCard 提示(后续步骤会走
// resolveRouteTarget / fs.statSync, 这里只钉住 guard 本身没误拦)。
const staleBody = feishuUi.cards.project!.staleGroupCard!;
const calls = (im.updateInteractiveCard as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.every(([, spec]) => (spec as { body?: string }).body !== staleBody)).toBe(true);
});
});
12 changes: 10 additions & 2 deletions apps/desktop/src/main/im/shared/cardActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,18 @@ export function createCardActionHandler(
im: ChannelIM,
event: IMCardActionEvent,
where: string,
body?: string,
): Promise<void> {
log.warn(
`${where}: group card lane unresolved (senderId=...${event.senderId.slice(-8)} is not a lane) — ` +
'refusing to attach so the takeover cannot land on the DM identity',
);
const body =
const text =
body ??
ui.cards.control.staleGroupCard ??
ui.cards.control.attachFailed('card lane unresolved (app restarted)');
try {
await im.updateInteractiveCard(event.messageId, cards.buildResolvedCard(body));
await im.updateInteractiveCard(event.messageId, cards.buildResolvedCard(text));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.warn(`${where}: stale group card patch failed (non-fatal): ${msg}`);
Expand Down Expand Up @@ -1232,6 +1234,12 @@ export function createCardActionHandler(
log.warn('project switch missing botAppId — ignoring');
return;
}
// 群卡认不出自己在哪条话题(app 重启过)时绝不切 —— 切换会落到私聊身份的
// 会话行上, 卡片还报「成功」。fail-closed, 让用户在目标话题里重发 /project。
if (isGroupCardWithLostLane(event)) {
await rejectStaleGroupCard(im, event, 'project:switch', projectUi.staleGroupCard);
return;
}
// 接管期间语义冲突(slash 层已拦, 这里兜旧卡片迟到按压)。
const attached = bindingStore.get({
channel,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/im/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ export interface ImUiTextPack {
switchFailed: (reason: string) => string;
/** /ctr 接管期间不支持切项目(先 /exctr)。 */
attachedUnsupported: string;
/** 群卡认不出自己在哪条话题(应用重启过)时的 fail-closed 提示。 */
staleGroupCard?: string;
/** 当前目录显示名为托管对话目录时的称呼。 */
dialogueName: string;
};
Expand Down