feat(FN-3969): restore room responder fallback in chat

Restores the room responder fallback mechanism in the chat module (`chat.ts`), with corresponding tests in `chat-manager.test.ts` and `chat.rooms.test.ts` verifying the behavior.

Fusion-Task-Id: FN-3969
This commit is contained in:
Fusion
2026-05-10 22:52:08 -07:00
committed by gsxdsm
parent a39985cabf
commit d56ed5205b
3 changed files with 102 additions and 1 deletions

View File

@@ -1954,6 +1954,7 @@ describe("ChatManager generation isolation", () => {
mockAgentStore.listAgents.mockResolvedValue([
{ id: "agent-001", name: "Avery", role: "executor", state: "idle" },
]);
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-001", name: "Avery", role: "executor", state: "idle" });
__setCreateResolvedAgentSession(async () => ({
session: {
@@ -1980,4 +1981,43 @@ describe("ChatManager generation isolation", () => {
});
});
it("sendRoomMessage still resolves room member responders when listAgents is unavailable", async () => {
(mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" });
(mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([
{ roomId: "room-1", agentId: "agent-001", role: "member", addedAt: "2026-01-01" },
]);
(mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({
id: "room-msg",
roomId: "room-1",
...input,
}));
mockAgentStore.listAgents.mockRejectedValue(new Error("agent listing offline"));
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-001", name: "Avery", role: "executor", state: "idle" });
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "Recovered room answer" }] },
},
provider: "test",
model: "test",
fallbackInfo: undefined,
} as any));
const chatManager = createChatManager();
await chatManager.sendRoomMessage("room-1", "hello @Avery");
const assistant = (mockChatStore as any).addRoomMessage.mock.calls
.map((call: any[]) => call[1])
.find((entry: any) => entry.role === "assistant");
expect(assistant).toMatchObject({
role: "assistant",
senderAgentId: "agent-001",
content: "Recovered room answer",
});
});
});

View File

@@ -10,6 +10,7 @@ const mockChatStore = {
const mockAgentStore = {
init: vi.fn(),
getAgent: vi.fn(),
listAgents: vi.fn(),
};
@@ -59,6 +60,7 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-a", name: "Alpha", role: "executor" }]);
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor" });
__setCreateResolvedAgentSession(async () => ({
session: {
@@ -85,10 +87,38 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" });
});
it("records non-member mentions and emits explanatory assistant note", async () => {
it("falls back to room-member getAgent lookup when listAgents fails", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockRejectedValue(new Error("list failed"));
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor" });
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Room reply" }],
},
},
provider: "test",
model: "test",
fallbackInfo: undefined,
} as any));
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = await manager.sendRoomMessage("room-1", "hello");
expect(result.responders).toEqual(["agent-a"]);
const assistantWrite = mockChatStore.addRoomMessage.mock.calls[1]?.[1];
expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" });
});
it("records non-member mentions and emits explanatory assistant note", async () => { mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([
{ id: "agent-a", name: "Alpha", role: "executor" },
{ id: "agent-z", name: "Zeta", role: "executor" },

View File

@@ -676,6 +676,23 @@ export class ChatManager {
}
}
private async getAgentById(agentId: string): Promise<Agent | null> {
if (!this.agentStore) {
return null;
}
try {
this.agentStoreReady ??= this.agentStore.init();
await this.agentStoreReady;
const agent = await this.agentStore.getAgent(agentId);
return agent ?? null;
} catch (agentLookupError) {
const message = agentLookupError instanceof Error ? agentLookupError.message : String(agentLookupError);
diagnostics.warn(`Failed to resolve room member agent ${agentId}: ${message}`);
return null;
}
}
/** A parsed @ mention of an agent in a chat message */
private async parseMentions(content: string, agents?: Agent[]): Promise<ChatMention[]> {
if (!this.agentStore) {
@@ -830,6 +847,20 @@ export class ChatManager {
const trimmedContent = content.trim();
const hasMentionCandidates = /@[\w-]+/.test(trimmedContent);
const availableAgents = await this.listAgentsForMentions();
const availableAgentsById = new Map(availableAgents.map((agent) => [agent.id, agent]));
for (const member of this.chatStore.listRoomMembers(roomId)) {
if (availableAgentsById.has(member.agentId)) {
continue;
}
const memberAgent = await this.getAgentById(member.agentId);
if (!memberAgent) {
continue;
}
availableAgentsById.set(memberAgent.id, memberAgent);
availableAgents.push(memberAgent);
}
const mentions = hasMentionCandidates ? await this.parseMentions(trimmedContent, availableAgents) : [];
const responderPlan = this.resolveRoomResponders(