## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
265 lines
9.2 KiB
TypeScript
265 lines
9.2 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||
import { render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import React from 'react';
|
||
|
||
import AddressInput from '../../src/components/AddressInput';
|
||
import * as api from '../../src/api/client';
|
||
import type { SessionCandidate } from '../../src/types';
|
||
|
||
/**
|
||
* 三段式地址补全。
|
||
*
|
||
* 每条断言都对应一个真实存在过或很容易犯的错:
|
||
* - 问错层(写了 @ 还在问 name 的候选)
|
||
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
|
||
* - 选中 name 后没自动补 `@`,人得自己敲
|
||
* - session 段选完还开着下拉,挡住下面的输入框
|
||
*/
|
||
|
||
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
|
||
function res(
|
||
kind: 'name' | 'path' | 'session',
|
||
suggestions: string[],
|
||
candidates?: Partial<SessionCandidate>[]
|
||
) {
|
||
return {
|
||
kind,
|
||
suggestions,
|
||
candidates: candidates?.map((c, i) => ({
|
||
alias: suggestions[i],
|
||
title: '',
|
||
source: 'mail' as const,
|
||
unread: 0,
|
||
...c
|
||
}))
|
||
};
|
||
}
|
||
|
||
/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */
|
||
function setup(initial = '') {
|
||
let current = initial;
|
||
const onChange = vi.fn((v: string) => {
|
||
current = v;
|
||
rerender();
|
||
});
|
||
const { rerender: rr } = render(
|
||
React.createElement(AddressInput, { value: current, onChange })
|
||
);
|
||
function rerender() {
|
||
rr(React.createElement(AddressInput, { value: current, onChange }));
|
||
}
|
||
return {
|
||
onChange,
|
||
value: () => current,
|
||
input: () => screen.getByRole('textbox') as HTMLInputElement
|
||
};
|
||
}
|
||
|
||
describe('AddressInput 三段式补全', () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('还没写 @ 时问 name 层', async () => {
|
||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||
const { input } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), 'ds');
|
||
|
||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||
// 问 name 层时不带任何参数
|
||
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
|
||
});
|
||
|
||
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
|
||
const spy = vi
|
||
.spyOn(api, 'suggestAddress')
|
||
.mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||
const { input } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), 'dsh@/home');
|
||
|
||
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
|
||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
|
||
});
|
||
|
||
it('写了 . 时带 name 与 path 去问 session 层', async () => {
|
||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
|
||
const { input } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), 'dsh@/home/x.ref');
|
||
|
||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
|
||
});
|
||
|
||
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
|
||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
|
||
const { input } = setup();
|
||
|
||
await userEvent.click(input());
|
||
// path 自身含 .:a.b/proj
|
||
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
|
||
|
||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
|
||
});
|
||
|
||
it('选中 name 候选后自动补 @', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||
const { input, value } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
|
||
await userEvent.click(screen.getByText('dsh'));
|
||
|
||
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
|
||
expect(value()).toBe('dsh@');
|
||
});
|
||
|
||
it('选中 path 候选后自动补 .', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||
const { input, value } = setup('dsh@');
|
||
|
||
await userEvent.click(input());
|
||
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
|
||
await userEvent.click(screen.getByText('/home/program/agentmail'));
|
||
|
||
expect(value()).toBe('dsh@/home/program/agentmail.');
|
||
});
|
||
|
||
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
|
||
const { input, value } = setup('dsh@/home/x.');
|
||
|
||
await userEvent.click(input());
|
||
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
|
||
await userEvent.click(screen.getByText('refactor'));
|
||
|
||
expect(value()).toBe('dsh@/home/x.refactor');
|
||
// session 是最后一段,选完还开着下拉会挡住下面的输入框
|
||
await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull());
|
||
});
|
||
|
||
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
|
||
// 三条候选,只有第二条的标题含「缓存」
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||
res(
|
||
'session',
|
||
['witty-planet', 'brisk-harbor', 'calm-river'],
|
||
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
|
||
)
|
||
);
|
||
const { input } = setup('dsh@/home/x.');
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), '缓存');
|
||
|
||
await waitFor(() => {
|
||
// 命中的是 brisk-harbor,标题必须还是它自己的
|
||
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
|
||
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
|
||
});
|
||
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
|
||
expect(screen.queryByText('重构导入路径')).toBeNull();
|
||
expect(screen.queryByText('witty-planet')).toBeNull();
|
||
});
|
||
|
||
it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||
res(
|
||
'session',
|
||
['ses-mirror', 'new'],
|
||
[
|
||
{ title: '平台侧在跑的会话', source: 'platform' },
|
||
{ source: 'new' }
|
||
]
|
||
)
|
||
);
|
||
const { input } = setup('dsh@/home/x.');
|
||
|
||
await userEvent.click(input());
|
||
|
||
await waitFor(() => {
|
||
// 不标的话人不知道这一封是「接入」一条已在跑的会话
|
||
expect(screen.getByText('平台')).toBeInTheDocument();
|
||
expect(screen.getByText('新建会话')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('未读数显示在候选上', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
|
||
);
|
||
const { input } = setup('dsh@/home/x.');
|
||
|
||
await userEvent.click(input());
|
||
|
||
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
|
||
});
|
||
|
||
it('键盘上下键移动选中项,Enter 采用', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||
const { input, value } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||
|
||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||
|
||
// 初始 active=0(dsh),下移一格到 opencode
|
||
expect(value()).toBe('opencode@');
|
||
});
|
||
|
||
it('Escape 关掉下拉但不清空已输入的内容', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
|
||
const { input, value } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), 'ds');
|
||
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
|
||
|
||
await userEvent.keyboard('{Escape}');
|
||
|
||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||
expect(value()).toBe('ds');
|
||
});
|
||
|
||
it('接口失败时静默清空候选,不炸也不弹错', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
|
||
const { input, value } = setup();
|
||
|
||
await userEvent.click(input());
|
||
await userEvent.type(input(), 'ds');
|
||
|
||
// 补全只是便利功能,失败不该阻止人手动输入完整地址
|
||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||
expect(value()).toBe('ds');
|
||
});
|
||
|
||
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
|
||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
|
||
|
||
let current = 'dsh@/home/x.a, ';
|
||
const onChange = vi.fn((v: string) => {
|
||
current = v;
|
||
});
|
||
render(
|
||
React.createElement(AddressInput, {
|
||
value: current,
|
||
onChange,
|
||
allowMultiple: true
|
||
})
|
||
);
|
||
|
||
await userEvent.click(screen.getByRole('textbox'));
|
||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||
await userEvent.click(screen.getByText('opencode'));
|
||
|
||
// 抄送场景:前面已填好的地址不能被补全覆盖
|
||
expect(current).toBe('dsh@/home/x.a, opencode@');
|
||
});
|
||
});
|