Files
MailUI4Agents/web/test/components/AddressInput.test.tsx
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .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),未手工拼写
2026-09-03 12:09:12 +08:00

265 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=0dsh下移一格到 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@');
});
});