refactor(adapters): brand btmsg/bttask/groups bridge interfaces with GroupId/AgentId

Apply branded types to all IPC bridge interfaces and function
parameters. Update test mock data with branded constructors.
This commit is contained in:
Hibryda 2026-03-11 22:56:52 +01:00
parent f928abd6ce
commit 0742309595
5 changed files with 82 additions and 77 deletions

View file

@ -29,6 +29,7 @@ import {
type BtmsgChannel,
type BtmsgChannelMessage,
} from './btmsg-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
@ -42,10 +43,10 @@ describe('btmsg-bridge', () => {
describe('BtmsgAgent camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const agent: BtmsgAgent = {
id: 'a1',
id: AgentId('a1'),
name: 'Coder',
role: 'developer',
groupId: 'g1', // was: group_id
groupId: GroupId('g1'), // was: group_id
tier: 1,
model: 'claude-4',
status: 'active',
@ -53,7 +54,7 @@ describe('btmsg-bridge', () => {
};
mockInvoke.mockResolvedValue([agent]);
const result = await getGroupAgents('g1');
const result = await getGroupAgents(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].groupId).toBe('g1');
@ -65,7 +66,7 @@ describe('btmsg-bridge', () => {
it('invokes btmsg_get_agents with groupId', async () => {
mockInvoke.mockResolvedValue([]);
await getGroupAgents('g1');
await getGroupAgents(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
});
});
@ -74,8 +75,8 @@ describe('btmsg-bridge', () => {
it('receives camelCase fields from Rust backend', async () => {
const msg: BtmsgMessage = {
id: 'm1',
fromAgent: 'a1', // was: from_agent
toAgent: 'a2', // was: to_agent
fromAgent: AgentId('a1'), // was: from_agent
toAgent: AgentId('a2'), // was: to_agent
content: 'hello',
read: false,
replyTo: null, // was: reply_to
@ -85,7 +86,7 @@ describe('btmsg-bridge', () => {
};
mockInvoke.mockResolvedValue([msg]);
const result = await getUnreadMessages('a2');
const result = await getUnreadMessages(AgentId('a2'));
expect(result[0].fromAgent).toBe('a1');
expect(result[0].toAgent).toBe('a2');
@ -100,8 +101,8 @@ describe('btmsg-bridge', () => {
it('receives camelCase fields including recipient info', async () => {
const feed: BtmsgFeedMessage = {
id: 'm1',
fromAgent: 'a1',
toAgent: 'a2',
fromAgent: AgentId('a1'),
toAgent: AgentId('a2'),
content: 'review this',
createdAt: '2026-01-01',
replyTo: null,
@ -112,7 +113,7 @@ describe('btmsg-bridge', () => {
};
mockInvoke.mockResolvedValue([feed]);
const result = await getAllFeed('g1');
const result = await getAllFeed(GroupId('g1'));
expect(result[0].senderName).toBe('Coder');
expect(result[0].recipientName).toBe('Reviewer');
@ -125,14 +126,14 @@ describe('btmsg-bridge', () => {
const channel: BtmsgChannel = {
id: 'ch1',
name: 'general',
groupId: 'g1', // was: group_id
createdBy: 'admin', // was: created_by
groupId: GroupId('g1'), // was: group_id
createdBy: AgentId('admin'), // was: created_by
memberCount: 5, // was: member_count
createdAt: '2026-01-01',
};
mockInvoke.mockResolvedValue([channel]);
const result = await getChannels('g1');
const result = await getChannels(GroupId('g1'));
expect(result[0].groupId).toBe('g1');
expect(result[0].createdBy).toBe('admin');
@ -145,7 +146,7 @@ describe('btmsg-bridge', () => {
const msg: BtmsgChannelMessage = {
id: 'cm1',
channelId: 'ch1', // was: channel_id
fromAgent: 'a1',
fromAgent: AgentId('a1'),
content: 'hello',
createdAt: '2026-01-01',
senderName: 'Coder',
@ -166,65 +167,65 @@ describe('btmsg-bridge', () => {
describe('IPC commands', () => {
it('getUnreadCount invokes btmsg_unread_count', async () => {
mockInvoke.mockResolvedValue(5);
const result = await getUnreadCount('a1');
const result = await getUnreadCount(AgentId('a1'));
expect(result).toBe(5);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' });
});
it('getHistory invokes btmsg_history', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory('a1', 'a2', 50);
await getHistory(AgentId('a1'), AgentId('a2'), 50);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
});
it('getHistory defaults limit to 20', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory('a1', 'a2');
await getHistory(AgentId('a1'), AgentId('a2'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 });
});
it('sendMessage invokes btmsg_send', async () => {
mockInvoke.mockResolvedValue('msg-id');
const result = await sendMessage('a1', 'a2', 'hello');
const result = await sendMessage(AgentId('a1'), AgentId('a2'), 'hello');
expect(result).toBe('msg-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
});
it('setAgentStatus invokes btmsg_set_status', async () => {
mockInvoke.mockResolvedValue(undefined);
await setAgentStatus('a1', 'active');
await setAgentStatus(AgentId('a1'), 'active');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' });
});
it('ensureAdmin invokes btmsg_ensure_admin', async () => {
mockInvoke.mockResolvedValue(undefined);
await ensureAdmin('g1');
await ensureAdmin(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' });
});
it('markRead invokes btmsg_mark_read', async () => {
mockInvoke.mockResolvedValue(undefined);
await markRead('a2', 'a1');
await markRead(AgentId('a2'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' });
});
it('sendChannelMessage invokes btmsg_channel_send', async () => {
mockInvoke.mockResolvedValue('cm-id');
const result = await sendChannelMessage('ch1', 'a1', 'hello channel');
const result = await sendChannelMessage('ch1', AgentId('a1'), 'hello channel');
expect(result).toBe('cm-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' });
});
it('createChannel invokes btmsg_create_channel', async () => {
mockInvoke.mockResolvedValue('ch-id');
const result = await createChannel('general', 'g1', 'admin');
const result = await createChannel('general', GroupId('g1'), AgentId('admin'));
expect(result).toBe('ch-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' });
});
it('addChannelMember invokes btmsg_add_channel_member', async () => {
mockInvoke.mockResolvedValue(undefined);
await addChannelMember('ch1', 'a1');
await addChannelMember('ch1', AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' });
});
});
@ -232,7 +233,7 @@ describe('btmsg-bridge', () => {
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(getGroupAgents('g1')).rejects.toThrow('btmsg database not found');
await expect(getGroupAgents(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -5,12 +5,13 @@
*/
import { invoke } from '@tauri-apps/api/core';
import type { GroupId, AgentId } from '../types/ids';
export interface BtmsgAgent {
id: string;
id: AgentId;
name: string;
role: string;
groupId: string;
groupId: GroupId;
tier: number;
model: string | null;
status: string;
@ -19,8 +20,8 @@ export interface BtmsgAgent {
export interface BtmsgMessage {
id: string;
fromAgent: string;
toAgent: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
read: boolean;
replyTo: string | null;
@ -31,8 +32,8 @@ export interface BtmsgMessage {
export interface BtmsgFeedMessage {
id: string;
fromAgent: string;
toAgent: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
createdAt: string;
replyTo: string | null;
@ -45,8 +46,8 @@ export interface BtmsgFeedMessage {
export interface BtmsgChannel {
id: string;
name: string;
groupId: string;
createdBy: string;
groupId: GroupId;
createdBy: AgentId;
memberCount: number;
createdAt: string;
}
@ -54,7 +55,7 @@ export interface BtmsgChannel {
export interface BtmsgChannelMessage {
id: string;
channelId: string;
fromAgent: string;
fromAgent: AgentId;
content: string;
createdAt: string;
senderName: string;
@ -64,70 +65,70 @@ export interface BtmsgChannelMessage {
/**
* Get all agents in a group with their unread counts.
*/
export async function getGroupAgents(groupId: string): Promise<BtmsgAgent[]> {
export async function getGroupAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
return invoke('btmsg_get_agents', { groupId });
}
/**
* Get unread message count for an agent.
*/
export async function getUnreadCount(agentId: string): Promise<number> {
export async function getUnreadCount(agentId: AgentId): Promise<number> {
return invoke('btmsg_unread_count', { agentId });
}
/**
* Get unread messages for an agent.
*/
export async function getUnreadMessages(agentId: string): Promise<BtmsgMessage[]> {
export async function getUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
return invoke('btmsg_unread_messages', { agentId });
}
/**
* Get conversation history between two agents.
*/
export async function getHistory(agentId: string, otherId: string, limit: number = 20): Promise<BtmsgMessage[]> {
export async function getHistory(agentId: AgentId, otherId: AgentId, limit: number = 20): Promise<BtmsgMessage[]> {
return invoke('btmsg_history', { agentId, otherId, limit });
}
/**
* Send a message from one agent to another.
*/
export async function sendMessage(fromAgent: string, toAgent: string, content: string): Promise<string> {
export async function sendMessage(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_send', { fromAgent, toAgent, content });
}
/**
* Update agent status (active/sleeping/stopped).
*/
export async function setAgentStatus(agentId: string, status: string): Promise<void> {
export async function setAgentStatus(agentId: AgentId, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status });
}
/**
* Ensure admin agent exists with contacts to all agents.
*/
export async function ensureAdmin(groupId: string): Promise<void> {
export async function ensureAdmin(groupId: GroupId): Promise<void> {
return invoke('btmsg_ensure_admin', { groupId });
}
/**
* Get all messages in group (admin global feed).
*/
export async function getAllFeed(groupId: string, limit: number = 100): Promise<BtmsgFeedMessage[]> {
export async function getAllFeed(groupId: GroupId, limit: number = 100): Promise<BtmsgFeedMessage[]> {
return invoke('btmsg_all_feed', { groupId, limit });
}
/**
* Mark all messages from sender to reader as read.
*/
export async function markRead(readerId: string, senderId: string): Promise<void> {
export async function markRead(readerId: AgentId, senderId: AgentId): Promise<void> {
return invoke('btmsg_mark_read', { readerId, senderId });
}
/**
* Get channels in a group.
*/
export async function getChannels(groupId: string): Promise<BtmsgChannel[]> {
export async function getChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
return invoke('btmsg_get_channels', { groupId });
}
@ -141,20 +142,20 @@ export async function getChannelMessages(channelId: string, limit: number = 100)
/**
* Send a message to a channel.
*/
export async function sendChannelMessage(channelId: string, fromAgent: string, content: string): Promise<string> {
export async function sendChannelMessage(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
}
/**
* Create a new channel.
*/
export async function createChannel(name: string, groupId: string, createdBy: string): Promise<string> {
export async function createChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
return invoke('btmsg_create_channel', { name, groupId, createdBy });
}
/**
* Add a member to a channel.
*/
export async function addChannelMember(channelId: string, agentId: string): Promise<void> {
export async function addChannelMember(channelId: string, agentId: AgentId): Promise<void> {
return invoke('btmsg_add_channel_member', { channelId, agentId });
}

View file

@ -18,6 +18,7 @@ import {
type Task,
type TaskComment,
} from './bttask-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
@ -34,9 +35,9 @@ describe('bttask-bridge', () => {
description: 'Critical fix',
status: 'progress',
priority: 'high',
assignedTo: 'a1', // was: assigned_to
createdBy: 'admin', // was: created_by
groupId: 'g1', // was: group_id
assignedTo: AgentId('a1'), // was: assigned_to
createdBy: AgentId('admin'), // was: created_by
groupId: GroupId('g1'), // was: group_id
parentTaskId: null, // was: parent_task_id
sortOrder: 1, // was: sort_order
createdAt: '2026-01-01', // was: created_at
@ -44,7 +45,7 @@ describe('bttask-bridge', () => {
};
mockInvoke.mockResolvedValue([task]);
const result = await listTasks('g1');
const result = await listTasks(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].assignedTo).toBe('a1');
@ -64,7 +65,7 @@ describe('bttask-bridge', () => {
const comment: TaskComment = {
id: 'c1',
taskId: 't1', // was: task_id
agentId: 'a1', // was: agent_id
agentId: AgentId('a1'), // was: agent_id
content: 'Working on it',
createdAt: '2026-01-01',
};
@ -84,7 +85,7 @@ describe('bttask-bridge', () => {
describe('IPC commands', () => {
it('listTasks invokes bttask_list', async () => {
mockInvoke.mockResolvedValue([]);
await listTasks('g1');
await listTasks(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
});
@ -102,14 +103,14 @@ describe('bttask-bridge', () => {
it('addTaskComment invokes bttask_add_comment', async () => {
mockInvoke.mockResolvedValue('c-id');
const result = await addTaskComment('t1', 'a1', 'Done!');
const result = await addTaskComment('t1', AgentId('a1'), 'Done!');
expect(result).toBe('c-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' });
});
it('createTask invokes bttask_create with all fields', async () => {
mockInvoke.mockResolvedValue('t-id');
const result = await createTask('Fix bug', 'desc', 'high', 'g1', 'admin', 'a1');
const result = await createTask('Fix bug', 'desc', 'high', GroupId('g1'), AgentId('admin'), AgentId('a1'));
expect(result).toBe('t-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Fix bug',
@ -123,7 +124,7 @@ describe('bttask-bridge', () => {
it('createTask invokes bttask_create without assignedTo', async () => {
mockInvoke.mockResolvedValue('t-id');
await createTask('Add tests', '', 'medium', 'g1', 'a1');
await createTask('Add tests', '', 'medium', GroupId('g1'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Add tests',
description: '',
@ -144,7 +145,7 @@ describe('bttask-bridge', () => {
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(listTasks('g1')).rejects.toThrow('btmsg database not found');
await expect(listTasks(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -1,6 +1,7 @@
// bttask Bridge — Tauri IPC adapter for task board
import { invoke } from '@tauri-apps/api/core';
import type { GroupId, AgentId } from '../types/ids';
export interface Task {
id: string;
@ -8,9 +9,9 @@ export interface Task {
description: string;
status: 'todo' | 'progress' | 'review' | 'done' | 'blocked';
priority: 'low' | 'medium' | 'high' | 'critical';
assignedTo: string | null;
createdBy: string;
groupId: string;
assignedTo: AgentId | null;
createdBy: AgentId;
groupId: GroupId;
parentTaskId: string | null;
sortOrder: number;
createdAt: string;
@ -20,12 +21,12 @@ export interface Task {
export interface TaskComment {
id: string;
taskId: string;
agentId: string;
agentId: AgentId;
content: string;
createdAt: string;
}
export async function listTasks(groupId: string): Promise<Task[]> {
export async function listTasks(groupId: GroupId): Promise<Task[]> {
return invoke<Task[]>('bttask_list', { groupId });
}
@ -37,7 +38,7 @@ export async function updateTaskStatus(taskId: string, status: string): Promise<
return invoke('bttask_update_status', { taskId, status });
}
export async function addTaskComment(taskId: string, agentId: string, content: string): Promise<string> {
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
}
@ -45,9 +46,9 @@ export async function createTask(
title: string,
description: string,
priority: string,
groupId: string,
createdBy: string,
assignedTo?: string,
groupId: GroupId,
createdBy: AgentId,
assignedTo?: AgentId,
): Promise<string> {
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
}

View file

@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups';
import type { SessionId, ProjectId } from '../types/ids';
export type { GroupsFile, ProjectConfig, GroupConfig };
@ -11,8 +12,8 @@ export interface MdFileEntry {
export interface AgentMessageRecord {
id: number;
session_id: string;
project_id: string;
session_id: SessionId;
project_id: ProjectId;
sdk_session_id: string | null;
message_type: string;
content: string;
@ -21,8 +22,8 @@ export interface AgentMessageRecord {
}
export interface ProjectAgentState {
project_id: string;
last_session_id: string;
project_id: ProjectId;
last_session_id: SessionId;
sdk_session_id: string | null;
status: string;
cost_usd: number;
@ -51,8 +52,8 @@ export async function discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]>
// --- Agent message persistence ---
export async function saveAgentMessages(
sessionId: string,
projectId: string,
sessionId: SessionId,
projectId: ProjectId,
sdkSessionId: string | undefined,
messages: AgentMessageRecord[],
): Promise<void> {
@ -64,7 +65,7 @@ export async function saveAgentMessages(
});
}
export async function loadAgentMessages(projectId: string): Promise<AgentMessageRecord[]> {
export async function loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
return invoke('agent_messages_load', { projectId });
}
@ -74,7 +75,7 @@ export async function saveProjectAgentState(state: ProjectAgentState): Promise<v
return invoke('project_agent_state_save', { state });
}
export async function loadProjectAgentState(projectId: string): Promise<ProjectAgentState | null> {
export async function loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
return invoke('project_agent_state_load', { projectId });
}
@ -82,8 +83,8 @@ export async function loadProjectAgentState(projectId: string): Promise<ProjectA
export interface SessionMetric {
id: number;
project_id: string;
session_id: string;
project_id: ProjectId;
session_id: SessionId;
start_time: number;
end_time: number;
peak_tokens: number;
@ -99,7 +100,7 @@ export async function saveSessionMetric(metric: Omit<SessionMetric, 'id'>): Prom
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
}
export async function loadSessionMetrics(projectId: string, limit = 20): Promise<SessionMetric[]> {
export async function loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetric[]> {
return invoke('session_metrics_load', { projectId, limit });
}