diff --git a/packages/app/src/electro-bridge/ipc/agent-host-api.ts b/packages/app/src/electro-bridge/ipc/agent-host-api.ts new file mode 100644 index 00000000..7955b1b2 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/agent-host-api.ts @@ -0,0 +1,46 @@ +import type { + AgentHostEvent, + AgentHostRendererRequest, + IAgentHostAPI, +} from "@/shared/types/agent-host"; +import type { LocalAIRendererIPC } from "./local-ai-api"; + +export const AGENT_HOST_CHANNELS = { + ENQUEUE: "agent-host:enqueue", + READY: "agent-host:ready", + LIST_JOBS: "agent-host:list-jobs", + CANCEL: "agent-host:cancel", + RESPOND: "agent-host:respond", + REQUEST: "agent-host:request", + EVENT: "agent-host:event", +} as const; + +export function createAgentHostAPI( + rendererIPC: LocalAIRendererIPC, +): IAgentHostAPI { + const invoke = rendererIPC.invoke.bind(rendererIPC) as ( + channel: string, + ...args: unknown[] + ) => Promise; + return { + ready: () => invoke(AGENT_HOST_CHANNELS.READY), + enqueue: (dispatch) => invoke(AGENT_HOST_CHANNELS.ENQUEUE, dispatch), + listJobs: () => invoke(AGENT_HOST_CHANNELS.LIST_JOBS), + cancel: (jobId) => invoke(AGENT_HOST_CHANNELS.CANCEL, jobId), + respond: (response) => invoke(AGENT_HOST_CHANNELS.RESPOND, response), + onRequest: (callback) => { + const handler = (_event: unknown, request: AgentHostRendererRequest) => + callback(request); + rendererIPC.on(AGENT_HOST_CHANNELS.REQUEST, handler); + return () => + rendererIPC.removeListener(AGENT_HOST_CHANNELS.REQUEST, handler); + }, + onEvent: (callback) => { + const handler = (_event: unknown, event: AgentHostEvent) => + callback(event); + rendererIPC.on(AGENT_HOST_CHANNELS.EVENT, handler); + return () => + rendererIPC.removeListener(AGENT_HOST_CHANNELS.EVENT, handler); + }, + }; +} diff --git a/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts b/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts new file mode 100644 index 00000000..21c23a56 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts @@ -0,0 +1,132 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentHost } from "@/electron/agent-host/host"; +import type { AgentHostRendererBridge } from "@/electron/agent-host/renderer-bridge"; +import type { AgentHostDispatch } from "@/shared/types/agent-host"; + +vi.mock("electron", () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, +})); + +import { AGENT_HOST_CHANNELS } from "./agent-host-api"; +import { setupAgentHostIPC } from "./agent-host-context"; + +type Handler = (event: FakeInvokeEvent, ...args: never[]) => unknown; + +class FakeWebContents extends EventEmitter { + readonly mainFrame = {}; + private destroyed = false; + + isDestroyed() { + return this.destroyed; + } +} + +interface FakeInvokeEvent { + sender: FakeWebContents; + senderFrame: object; +} + +function event(sender: FakeWebContents): FakeInvokeEvent { + return { sender, senderFrame: sender.mainFrame }; +} + +function mainIPC() { + const handlers = new Map(); + return { + handlers, + ipc: { + handle: (channel: string, handler: Handler) => + handlers.set(channel, handler), + removeHandler: (channel: string) => handlers.delete(channel), + }, + }; +} + +const dispatch: AgentHostDispatch = { + channelId: "channel", + conversationId: "conversation", + triggerMessageId: "message", + contextMessageIds: ["message"], + mode: "direct", + offeredAgentMemberIds: ["agent:fizz"], + targets: [{ agentId: "fizz", memberId: "agent:fizz" }], + chain: { hops: 0, invoked: ["agent:fizz"] }, +}; + +describe("Agent Host IPC", () => { + beforeEach(() => vi.clearAllMocks()); + + it("starts only after the renderer is ready and forwards durable dispatches", async () => { + const sender = new FakeWebContents(); + const host = { + start: vi.fn(), + enqueue: vi.fn(async () => [{ id: "job" }]), + listJobs: vi.fn(async () => []), + cancel: vi.fn(async () => true), + } as unknown as AgentHost; + const bridge = { + respond: vi.fn(() => true), + } as unknown as AgentHostRendererBridge; + const { handlers, ipc } = mainIPC(); + setupAgentHostIPC( + { + host, + bridge, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + + expect( + await handlers.get(AGENT_HOST_CHANNELS.READY)?.(event(sender)), + ).toEqual({ success: true }); + expect(host.start).toHaveBeenCalledOnce(); + expect( + await handlers.get(AGENT_HOST_CHANNELS.ENQUEUE)?.( + event(sender), + dispatch as never, + ), + ).toEqual({ success: true, jobs: [{ id: "job" }] }); + expect(host.enqueue).toHaveBeenCalledWith(dispatch); + }); + + it("rejects another renderer and does not accept forged responses", async () => { + const allowed = new FakeWebContents(); + const attacker = new FakeWebContents(); + const host = { + enqueue: vi.fn(), + } as unknown as AgentHost; + const bridge = { + respond: vi.fn(), + } as unknown as AgentHostRendererBridge; + const { handlers, ipc } = mainIPC(); + setupAgentHostIPC( + { + host, + bridge, + getAllowedWebContents: () => allowed as never, + }, + ipc as never, + ); + + expect( + await handlers.get(AGENT_HOST_CHANNELS.ENQUEUE)?.( + event(attacker), + dispatch as never, + ), + ).toMatchObject({ + success: false, + error: expect.stringContaining("allowed"), + }); + expect( + await handlers.get(AGENT_HOST_CHANNELS.RESPOND)?.(event(attacker), { + requestId: "request", + success: true, + data: {}, + } as never), + ).toMatchObject({ success: false }); + expect(host.enqueue).not.toHaveBeenCalled(); + expect(bridge.respond).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/electro-bridge/ipc/agent-host-context.ts b/packages/app/src/electro-bridge/ipc/agent-host-context.ts new file mode 100644 index 00000000..48da84e6 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/agent-host-context.ts @@ -0,0 +1,130 @@ +import type { + AgentHostDispatch, + AgentHostRendererResponse, +} from "@/shared/types/agent-host"; +import type { AgentHost } from "@/electron/agent-host/host"; +import type { AgentHostRendererBridge } from "@/electron/agent-host/renderer-bridge"; +import { + ipcMain, + type IpcMain, + type IpcMainInvokeEvent, + type WebContents, +} from "electron"; +import { AGENT_HOST_CHANNELS } from "./agent-host-api"; +import { isAllowedLocalAISender } from "./local-ai-context"; + +export interface AgentHostIPCOptions { + host?: AgentHost; + bridge?: AgentHostRendererBridge; + getAllowedWebContents: () => WebContents | WebContents[] | null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function allowed( + event: IpcMainInvokeEvent, + options: AgentHostIPCOptions, +): boolean { + return isAllowedLocalAISender(event, options.getAllowedWebContents()); +} + +export function setupAgentHostIPC( + options: AgentHostIPCOptions, + mainIPC: Pick = ipcMain, +): void { + for (const channel of [ + AGENT_HOST_CHANNELS.ENQUEUE, + AGENT_HOST_CHANNELS.READY, + AGENT_HOST_CHANNELS.LIST_JOBS, + AGENT_HOST_CHANNELS.CANCEL, + AGENT_HOST_CHANNELS.RESPOND, + ]) { + mainIPC.removeHandler(channel); + } + + mainIPC.handle(AGENT_HOST_CHANNELS.READY, async (event) => { + if (!allowed(event, options)) { + return { success: false, error: "Agent Host IPC sender is not allowed." }; + } + if (!options.host) { + return { success: false, error: "Agent Host is unavailable." }; + } + options.host.start(); + return { success: true }; + }); + + mainIPC.handle( + AGENT_HOST_CHANNELS.ENQUEUE, + async (event, dispatch: AgentHostDispatch) => { + if (!allowed(event, options)) { + return { + success: false, + error: "Agent Host IPC sender is not allowed.", + }; + } + if (!options.host) { + return { success: false, error: "Agent Host is unavailable." }; + } + try { + return { success: true, jobs: await options.host.enqueue(dispatch) }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + ); + + mainIPC.handle(AGENT_HOST_CHANNELS.LIST_JOBS, async (event) => { + if (!allowed(event, options)) { + return { success: false, error: "Agent Host IPC sender is not allowed." }; + } + if (!options.host) { + return { success: false, error: "Agent Host is unavailable." }; + } + try { + return { success: true, jobs: await options.host.listJobs() }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }); + + mainIPC.handle(AGENT_HOST_CHANNELS.CANCEL, async (event, jobId: unknown) => { + if (!allowed(event, options)) { + return { success: false, error: "Agent Host IPC sender is not allowed." }; + } + if (!options.host) { + return { success: false, error: "Agent Host is unavailable." }; + } + if (typeof jobId !== "string" || !jobId) { + return { success: false, error: "A job id is required." }; + } + try { + return { + success: true, + cancelled: await options.host.cancel(jobId), + }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }); + + mainIPC.handle( + AGENT_HOST_CHANNELS.RESPOND, + async (event, response: AgentHostRendererResponse) => { + if (!allowed(event, options)) { + return { + success: false, + error: "Agent Host IPC sender is not allowed.", + }; + } + if (!options.bridge) { + return { success: false, error: "Agent Host bridge is unavailable." }; + } + return { + success: true, + accepted: options.bridge.respond(response), + }; + }, + ); +} diff --git a/packages/app/src/electro-bridge/ipc/listeners-register.ts b/packages/app/src/electro-bridge/ipc/listeners-register.ts index 853d6c2b..3051df28 100644 --- a/packages/app/src/electro-bridge/ipc/listeners-register.ts +++ b/packages/app/src/electro-bridge/ipc/listeners-register.ts @@ -2,6 +2,8 @@ import { WindowSizeConfig } from "@/electron/windows/window-size"; import { ThemeMode } from "@/shared/types/electron"; import type { LocalAIRuntimeService } from "@/shared/types/local-ai"; +import type { AgentHost } from "@/electron/agent-host/host"; +import type { AgentHostRendererBridge } from "@/electron/agent-host/renderer-bridge"; import { BrowserWindow, ipcMain, @@ -34,6 +36,7 @@ import { import { setupLoggerIPC } from "./logger-context"; import { setupLocalAIIPC } from "./local-ai-context"; import { setupMCPIPC } from "./mcp-context"; +import { setupAgentHostIPC } from "./agent-host-context"; // Extended interface that includes additional methods beyond IPCServer interface ElectronAPI extends IPCServer { @@ -97,6 +100,8 @@ export interface ListenerOptions { mainWindow?: () => BrowserWindow | null; registerGlobalShortcuts?: () => void; localAIRuntime?: LocalAIRuntimeService; + agentHost?: AgentHost; + agentHostBridge?: AgentHostRendererBridge; /** Extra senders (currently the web bridge) allowed to drive local AI. */ extraLocalAISenders?: () => WebContents[]; /** Registration target; the web bridge swaps in a recording proxy. */ @@ -257,5 +262,18 @@ export function registerListeners(options: ListenerOptions = {}) { }, ipc, ); + setupAgentHostIPC( + { + host: options.agentHost, + bridge: options.agentHostBridge, + getAllowedWebContents: () => { + const window = options.mainWindow?.(); + const renderer = + window && !window.isDestroyed() ? [window.webContents] : []; + return [...renderer, ...(options.extraLocalAISenders?.() ?? [])]; + }, + }, + ipc, + ); console.log("All IPC listeners registered successfully"); } diff --git a/packages/app/src/electron/agent-host/executor.test.ts b/packages/app/src/electron/agent-host/executor.test.ts new file mode 100644 index 00000000..d1fb94dd --- /dev/null +++ b/packages/app/src/electron/agent-host/executor.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentHostJob } from "@/shared/types/agent-host"; +import type { + LocalAIRuntimeService, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import type { AgentHostRendererBridge } from "./renderer-bridge"; +import { LocalAiAgentHostExecutor } from "./executor"; + +const job: AgentHostJob = { + id: "job", + channelId: "channel", + conversationId: "trusted-conversation", + triggerMessageId: "message", + contextMessageIds: ["message"], + mode: "direct", + offeredAgentMemberIds: ["agent:trusted"], + agentId: "trusted", + agentMemberId: "agent:trusted", + chain: { hops: 0, invoked: ["agent:trusted"] }, + status: "running", + attempts: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +describe("LocalAiAgentHostExecutor", () => { + it("fences renderer-prepared identity to the durable job", async () => { + const startChat = vi.fn( + async (_request: unknown, emit: (event: LocalAIStreamEvent) => void) => { + emit({ + type: "interaction", + requestId: "request", + interactionId: "interaction", + kind: "input", + name: "workspace:query", + prompt: "Send a message", + input: { + kind: "send_message", + viewerMemberId: "agent:trusted", + channelId: "channel", + content: "done", + }, + }); + }, + ); + const runtime = { startChat } as unknown as LocalAIRuntimeService; + const bridge = { + request: vi.fn(async () => ({ + request: { + requestId: "request", + turnId: "turn", + conversationId: "forged-conversation", + providerId: "codex-cli", + concurrent: false, + operation: { kind: "bootstrap", messages: [] }, + agent: { id: "forged", memberId: "agent:forged" }, + }, + })), + } as unknown as AgentHostRendererBridge; + const executor = new LocalAiAgentHostExecutor(runtime, bridge); + + await executor.execute(job, vi.fn()); + + expect(startChat).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "trusted-conversation", + concurrent: true, + agent: { id: "trusted", memberId: "agent:trusted" }, + }), + expect.any(Function), + ); + }); + + it("rejects provider stream errors instead of completing the job", async () => { + const runtime = { + startChat: async ( + _request: unknown, + emit: (event: LocalAIStreamEvent) => void, + ) => { + emit({ + type: "error", + requestId: "request", + error: { name: "ProviderError", message: "model unavailable" }, + }); + }, + } as unknown as LocalAIRuntimeService; + const bridge = { + request: vi.fn(async () => ({ + request: { + requestId: "request", + turnId: "turn", + conversationId: "trusted-conversation", + providerId: "codex-cli", + operation: { kind: "bootstrap", messages: [] }, + agent: { id: "trusted", memberId: "agent:trusted" }, + }, + })), + } as unknown as AgentHostRendererBridge; + + await expect( + new LocalAiAgentHostExecutor(runtime, bridge).execute(job, vi.fn()), + ).rejects.toThrow("model unavailable"); + }); + + it("rejects a silent direct offer but permits an open-floor pass", async () => { + const runtime = { + startChat: async () => undefined, + } as unknown as LocalAIRuntimeService; + const bridge = { + request: vi.fn(async () => ({ + request: { + requestId: "request", + turnId: "turn", + conversationId: "trusted-conversation", + providerId: "codex-cli", + operation: { kind: "bootstrap", messages: [] }, + agent: { id: "trusted", memberId: "agent:trusted" }, + }, + })), + } as unknown as AgentHostRendererBridge; + const executor = new LocalAiAgentHostExecutor(runtime, bridge); + + await expect(executor.execute(job, vi.fn())).rejects.toThrow( + "completed a direct offer without sending a message", + ); + await expect( + executor.execute({ ...job, mode: "open-floor" }, vi.fn()), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/app/src/electron/agent-host/executor.ts b/packages/app/src/electron/agent-host/executor.ts new file mode 100644 index 00000000..4a410f5f --- /dev/null +++ b/packages/app/src/electron/agent-host/executor.ts @@ -0,0 +1,73 @@ +import type { + AgentHostEvent, + AgentHostJob, + PreparedAgentHostTurn, +} from "@/shared/types/agent-host"; +import type { LocalAIRuntimeService } from "@/shared/types/local-ai"; +import { WORKSPACE_QUERY_INTERACTION } from "@/shared/types/workspace-perception"; +import type { AgentHostExecutor } from "./host"; +import type { AgentHostRendererBridge } from "./renderer-bridge"; + +export class LocalAiAgentHostExecutor implements AgentHostExecutor { + private readonly activeRequests = new Map(); + + constructor( + private readonly runtime: LocalAIRuntimeService, + private readonly bridge: AgentHostRendererBridge, + ) {} + + async execute( + job: AgentHostJob, + emit: (event: AgentHostEvent) => void, + ): Promise { + let streamError: Error | undefined; + let sentMessage = false; + try { + const prepared = await this.bridge.request({ + kind: "prepare-turn", + job, + }); + const request = { + ...prepared.request, + conversationId: job.conversationId, + concurrent: true, + agent: { + ...prepared.request.agent, + id: job.agentId, + memberId: job.agentMemberId, + }, + }; + this.activeRequests.set(job.id, request.requestId); + await this.runtime.startChat(request, (event) => { + if (event.type === "error") { + streamError = new Error(event.error.message); + } + if ( + event.type === "interaction" && + event.name === WORKSPACE_QUERY_INTERACTION && + typeof event.input === "object" && + event.input !== null && + "kind" in event.input && + event.input.kind === "send_message" + ) { + sentMessage = true; + } + emit({ type: "stream", jobId: job.id, event }); + }); + if (streamError) throw streamError; + if (job.mode === "direct" && !sentMessage) { + throw new Error( + "The agent completed a direct offer without sending a message.", + ); + } + } finally { + this.activeRequests.delete(job.id); + } + } + + async cancel(job: AgentHostJob): Promise { + const requestId = this.activeRequests.get(job.id); + if (!requestId) return true; + return await this.runtime.abort(requestId); + } +} diff --git a/packages/app/src/electron/agent-host/host.test.ts b/packages/app/src/electron/agent-host/host.test.ts new file mode 100644 index 00000000..9cca3354 --- /dev/null +++ b/packages/app/src/electron/agent-host/host.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + AgentHostDispatch, + AgentHostJob, +} from "@/shared/types/agent-host"; +import { AgentHost, type AgentHostExecutor } from "./host"; +import { InMemoryAgentHostJobRepository } from "./repository"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function dispatch( + conversationId: string, + members = ["agent:a"], + triggerMessageId = `message:${conversationId}`, +): AgentHostDispatch { + return { + channelId: `channel:${conversationId}`, + conversationId, + triggerMessageId, + contextMessageIds: [triggerMessageId], + mode: members.length > 1 ? "open-floor" : "direct", + offeredAgentMemberIds: [...members], + targets: members.map((memberId) => ({ + memberId, + agentId: memberId.slice("agent:".length), + })), + chain: { hops: 0, invoked: [...members] }, + }; +} + +function storedJob(status: AgentHostJob["status"]): AgentHostJob { + return { + id: "stored", + channelId: "channel:c1", + conversationId: "c1", + triggerMessageId: "message:c1", + contextMessageIds: ["message:c1"], + mode: "direct", + offeredAgentMemberIds: ["agent:a"], + agentId: "a", + agentMemberId: "agent:a", + chain: { hops: 0, invoked: ["agent:a"] }, + status, + attempts: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +describe("AgentHost", () => { + it("deduplicates the same trigger and actor", async () => { + const execute = vi.fn(async () => undefined); + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute }, + startPaused: true, + createId: () => "job-1", + }); + const first = await host.enqueue(dispatch("c1")); + const second = await host.enqueue(dispatch("c1")); + expect(second[0].id).toBe(first[0].id); + expect(await host.listJobs()).toHaveLength(1); + }); + + it("rejects an offer without its frozen trigger boundary", async () => { + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute: async () => undefined }, + }); + await expect( + host.enqueue({ + ...dispatch("c1"), + contextMessageIds: ["different"], + }), + ).rejects.toThrow("Invalid Agent Host dispatch"); + }); + + it("runs different actors in one conversation concurrently", async () => { + const gates = new Map>>(); + const execute = vi.fn((job: AgentHostJob) => { + const gate = deferred(); + gates.set(job.agentMemberId, gate); + return gate.promise; + }); + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute }, + maxConcurrency: 3, + }); + await host.enqueue(dispatch("same", ["agent:a", "agent:b"])); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + gates.get("agent:a")?.resolve(); + gates.get("agent:b")?.resolve(); + }); + + it("serializes one actor while allowing another actor to work", async () => { + const gates = new Map>>(); + const execute = vi.fn((job: AgentHostJob) => { + const gate = deferred(); + gates.set(job.id, gate); + return gate.promise; + }); + let nextId = 0; + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute }, + maxConcurrency: 3, + createId: () => `job-${++nextId}`, + }); + await host.enqueue(dispatch("same", ["agent:a"], "message:one")); + await host.enqueue(dispatch("same", ["agent:a"], "message:two")); + await host.enqueue(dispatch("same", ["agent:b"], "message:three")); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + expect(execute.mock.calls.map(([job]) => job.agentMemberId)).toEqual([ + "agent:a", + "agent:b", + ]); + gates.get("job-1")?.resolve(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(3)); + gates.get("job-2")?.resolve(); + gates.get("job-3")?.resolve(); + }); + + it("marks a running job interrupted instead of replaying it", async () => { + const running = storedJob("running"); + const repository = new InMemoryAgentHostJobRepository([running]); + const execute = vi.fn(async () => undefined); + const host = new AgentHost({ repository, executor: { execute } }); + await host.initialize(); + expect((await host.listJobs())[0]).toMatchObject({ + status: "interrupted", + attempts: 1, + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("cancels the exact running provider request", async () => { + const gate = deferred(); + const cancel = vi.fn(async () => { + gate.resolve(); + return true; + }); + const executor: AgentHostExecutor = { + execute: () => gate.promise, + cancel, + }; + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor, + createId: () => "job-1", + }); + await host.enqueue(dispatch("c1")); + await vi.waitFor(async () => + expect((await host.listJobs())[0].status).toBe("running"), + ); + expect(await host.cancel("job-1")).toBe(true); + expect(cancel).toHaveBeenCalledWith( + expect.objectContaining({ agentMemberId: "agent:a" }), + ); + expect((await host.listJobs())[0].status).toBe("cancelled"); + }); +}); diff --git a/packages/app/src/electron/agent-host/host.ts b/packages/app/src/electron/agent-host/host.ts new file mode 100644 index 00000000..284020a2 --- /dev/null +++ b/packages/app/src/electron/agent-host/host.ts @@ -0,0 +1,298 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentHostDispatch, + AgentHostEvent, + AgentHostJob, +} from "@/shared/types/agent-host"; +import type { AgentHostJobRepository } from "./repository"; + +export interface AgentHostExecutor { + execute( + job: AgentHostJob, + emit: (event: AgentHostEvent) => void, + ): Promise; + cancel?(job: AgentHostJob): Promise | boolean; +} + +export interface AgentHostOptions { + repository: AgentHostJobRepository; + executor: AgentHostExecutor; + maxConcurrency?: number; + now?: () => Date; + createId?: () => string; + startPaused?: boolean; +} + +type Listener = (event: AgentHostEvent) => void; + +const TERMINAL = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); +const IDENTIFIER = /^[A-Za-z0-9._:-]{1,256}$/; +const MAX_CONTEXT_MESSAGES = 500; + +function actorKey(job: Pick) { + return `${job.conversationId}\0${job.agentMemberId}`; +} + +function validateDispatch(dispatch: AgentHostDispatch): void { + if ( + !IDENTIFIER.test(dispatch.channelId) || + !IDENTIFIER.test(dispatch.conversationId) || + !IDENTIFIER.test(dispatch.triggerMessageId) || + !Array.isArray(dispatch.contextMessageIds) || + dispatch.contextMessageIds.length === 0 || + dispatch.contextMessageIds.length > MAX_CONTEXT_MESSAGES || + !dispatch.contextMessageIds.includes(dispatch.triggerMessageId) || + !dispatch.contextMessageIds.every((id) => IDENTIFIER.test(id)) || + !["open-floor", "direct"].includes(dispatch.mode) || + !Array.isArray(dispatch.offeredAgentMemberIds) || + dispatch.offeredAgentMemberIds.length > 16 || + !dispatch.offeredAgentMemberIds.every( + (id) => IDENTIFIER.test(id) && id.startsWith("agent:"), + ) || + !Array.isArray(dispatch.targets) || + dispatch.targets.length === 0 || + dispatch.targets.length > 16 || + !dispatch.targets.every( + (target) => + IDENTIFIER.test(target.agentId) && + IDENTIFIER.test(target.memberId) && + target.memberId.startsWith("agent:") && + dispatch.chain.invoked.includes(target.memberId) && + dispatch.offeredAgentMemberIds.includes(target.memberId), + ) || + !Number.isInteger(dispatch.chain.hops) || + dispatch.chain.hops < 0 || + dispatch.chain.hops > 3 || + !Array.isArray(dispatch.chain.invoked) || + dispatch.chain.invoked.length > 16 || + !dispatch.chain.invoked.every((id) => IDENTIFIER.test(id)) + ) { + throw new Error("Invalid Agent Host dispatch."); + } +} + +export class AgentHost { + private readonly repository: AgentHostJobRepository; + private readonly executor: AgentHostExecutor; + private readonly maxConcurrency: number; + private readonly now: () => Date; + private readonly createId: () => string; + private readonly jobs = new Map(); + private readonly listeners = new Set(); + private readonly running = new Set(); + private readonly activeActors = new Set(); + private ready: Promise; + private accepting = true; + private drainQueued = false; + private paused: boolean; + + constructor(options: AgentHostOptions) { + this.repository = options.repository; + this.executor = options.executor; + this.maxConcurrency = Math.max(1, options.maxConcurrency ?? 3); + this.now = options.now ?? (() => new Date()); + this.createId = options.createId ?? randomUUID; + this.paused = options.startPaused ?? false; + this.ready = this.hydrate(); + } + + private async hydrate(): Promise { + const jobs = await this.repository.list(); + for (const stored of jobs) { + const job = structuredClone(stored); + if (job.status === "running") { + job.status = "interrupted"; + job.error = + "Agent work was interrupted by an application restart and was not replayed to avoid duplicating tool actions."; + job.completedAt = this.now().toISOString(); + job.updatedAt = job.completedAt; + await this.repository.put(job); + } + this.jobs.set(job.id, job); + } + this.scheduleDrain(); + } + + async initialize(): Promise { + await this.ready; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + start(): void { + this.paused = false; + this.scheduleDrain(); + } + + async listJobs(): Promise { + await this.ready; + return [...this.jobs.values()] + .sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ) + .map((job) => structuredClone(job)); + } + + async enqueue(dispatch: AgentHostDispatch): Promise { + await this.ready; + if (!this.accepting) throw new Error("Agent Host is stopping."); + validateDispatch(dispatch); + const now = this.now().toISOString(); + const created: AgentHostJob[] = []; + const targets = dispatch.targets.filter( + (target, index, all) => + all.findIndex((candidate) => candidate.memberId === target.memberId) === + index, + ); + for (const target of targets) { + const duplicate = [...this.jobs.values()].find( + (job) => + job.triggerMessageId === dispatch.triggerMessageId && + job.agentMemberId === target.memberId && + job.status !== "cancelled" && + job.status !== "failed" && + job.status !== "interrupted", + ); + if (duplicate) { + created.push(structuredClone(duplicate)); + continue; + } + const job: AgentHostJob = { + id: this.createId(), + channelId: dispatch.channelId, + conversationId: dispatch.conversationId, + triggerMessageId: dispatch.triggerMessageId, + contextMessageIds: [...dispatch.contextMessageIds], + mode: dispatch.mode, + offeredAgentMemberIds: [...dispatch.offeredAgentMemberIds], + agentId: target.agentId, + agentMemberId: target.memberId, + chain: structuredClone(dispatch.chain), + status: "queued", + attempts: 0, + createdAt: now, + updatedAt: now, + }; + this.jobs.set(job.id, job); + await this.repository.put(job); + this.emit({ type: "job", job }); + created.push(structuredClone(job)); + } + this.scheduleDrain(); + return created; + } + + async cancel(jobId: string): Promise { + await this.ready; + const job = this.jobs.get(jobId); + if (!job || TERMINAL.has(job.status)) return false; + if (job.status === "running") { + const cancelled = await this.executor.cancel?.(structuredClone(job)); + if (cancelled === false) return false; + } + await this.finish(job, "cancelled", "Cancelled by the user."); + return true; + } + + async dispose(): Promise { + await this.ready; + this.accepting = false; + await Promise.all( + [...this.running].map((id) => this.cancel(id).catch(() => false)), + ); + } + + private scheduleDrain(): void { + if (this.drainQueued || !this.accepting || this.paused) return; + this.drainQueued = true; + queueMicrotask(() => { + this.drainQueued = false; + void this.drain(); + }); + } + + private async drain(): Promise { + await this.ready; + while (this.running.size < this.maxConcurrency) { + const next = [...this.jobs.values()] + .filter( + (job) => + job.status === "queued" && !this.activeActors.has(actorKey(job)), + ) + .sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + )[0]; + if (!next) return; + this.running.add(next.id); + this.activeActors.add(actorKey(next)); + void this.run(next); + } + } + + private async run(job: AgentHostJob): Promise { + const startedAt = this.now().toISOString(); + job.status = "running"; + job.attempts += 1; + job.startedAt = startedAt; + job.updatedAt = startedAt; + await this.repository.put(job); + this.emit({ type: "job", job }); + + try { + await this.executor.execute(structuredClone(job), (event) => + this.emit(event), + ); + if (job.status !== "running") return; + await this.finish(job, "completed"); + } catch (error) { + if (job.status === "running") { + await this.finish( + job, + "failed", + error instanceof Error ? error.message : "Agent work failed.", + ); + } + } finally { + this.running.delete(job.id); + this.activeActors.delete(actorKey(job)); + this.scheduleDrain(); + } + } + + private async finish( + job: AgentHostJob, + status: Extract< + AgentHostJob["status"], + "completed" | "failed" | "cancelled" + >, + error?: string, + ): Promise { + const completedAt = this.now().toISOString(); + job.status = status; + job.error = error; + job.completedAt = completedAt; + job.updatedAt = completedAt; + await this.repository.put(job); + this.emit({ type: "job", job }); + } + + private emit(event: AgentHostEvent): void { + const safe = + event.type === "job" + ? { ...event, job: structuredClone(event.job) } + : structuredClone(event); + for (const listener of this.listeners) listener(safe); + } +} diff --git a/packages/app/src/electron/agent-host/renderer-bridge.ts b/packages/app/src/electron/agent-host/renderer-bridge.ts new file mode 100644 index 00000000..a388f73c --- /dev/null +++ b/packages/app/src/electron/agent-host/renderer-bridge.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import type { WebContents } from "electron"; +import type { + AgentHostEvent, + AgentHostRendererRequest, + AgentHostRendererResponse, +} from "@/shared/types/agent-host"; +import { AGENT_HOST_CHANNELS } from "@/electro-bridge/ipc/agent-host-api"; + +type RendererRequestInput = AgentHostRendererRequest extends infer Request + ? Request extends { id: string } + ? Omit + : never + : never; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timeout: ReturnType; +} + +export class AgentHostRendererBridge { + private readonly pending = new Map(); + + constructor( + private readonly getRenderer: () => WebContents | null | undefined, + private readonly timeoutMs = 30_000, + ) {} + + async request(input: RendererRequestInput): Promise { + const renderer = this.getRenderer(); + if (!renderer || renderer.isDestroyed()) { + throw new Error( + "The Convera renderer is unavailable; background agent work cannot access its channel.", + ); + } + const request = { + ...input, + id: randomUUID(), + } as AgentHostRendererRequest; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(request.id); + reject( + new Error( + `The renderer did not answer Agent Host request ${request.kind}.`, + ), + ); + }, this.timeoutMs); + this.pending.set(request.id, { + resolve: (value) => resolve(value as T), + reject, + timeout, + }); + renderer.send(AGENT_HOST_CHANNELS.REQUEST, request); + }); + } + + respond(response: AgentHostRendererResponse): boolean { + const pending = this.pending.get(response.requestId); + if (!pending) return false; + this.pending.delete(response.requestId); + clearTimeout(pending.timeout); + if (response.success) pending.resolve(response.data); + else + pending.reject( + new Error( + response.error || "The renderer rejected an Agent Host request.", + ), + ); + return true; + } + + emit(event: AgentHostEvent): void { + const renderer = this.getRenderer(); + if (!renderer || renderer.isDestroyed()) return; + renderer.send(AGENT_HOST_CHANNELS.EVENT, event); + } + + dispose(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Agent Host renderer bridge stopped.")); + } + this.pending.clear(); + } +} diff --git a/packages/app/src/electron/agent-host/repository.test.ts b/packages/app/src/electron/agent-host/repository.test.ts new file mode 100644 index 00000000..97d3b456 --- /dev/null +++ b/packages/app/src/electron/agent-host/repository.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentHostJob } from "@/shared/types/agent-host"; +import { JsonAgentHostJobRepository } from "./repository"; + +const temporaryDirectories: string[] = []; + +function job(id: string, status: AgentHostJob["status"]): AgentHostJob { + const timestamp = new Date( + 1_750_000_000_000 + Number(id) * 1_000, + ).toISOString(); + return { + id, + channelId: "channel", + conversationId: "conversation", + triggerMessageId: `message-${id}`, + contextMessageIds: [`message-${id}`], + mode: "direct", + offeredAgentMemberIds: ["agent:a"], + agentId: "a", + agentMemberId: "agent:a", + chain: { hops: 0, invoked: ["agent:a"] }, + status, + attempts: status === "queued" ? 0 : 1, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("JsonAgentHostJobRepository", () => { + it("round-trips jobs and updates by stable id", async () => { + const directory = await mkdtemp(join(tmpdir(), "convera-agent-host-")); + temporaryDirectories.push(directory); + const repository = new JsonAgentHostJobRepository({ + path: join(directory, "jobs.json"), + }); + await repository.put(job("1", "queued")); + await repository.put(job("1", "completed")); + + expect(await repository.list()).toEqual([job("1", "completed")]); + }); + + it("prunes only the oldest terminal jobs", async () => { + const directory = await mkdtemp(join(tmpdir(), "convera-agent-host-")); + temporaryDirectories.push(directory); + const repository = new JsonAgentHostJobRepository({ + path: join(directory, "jobs.json"), + maxTerminalJobs: 1, + }); + await repository.put(job("1", "completed")); + await repository.put(job("2", "failed")); + await repository.put(job("3", "queued")); + + expect((await repository.list()).map(({ id }) => id).sort()).toEqual([ + "2", + "3", + ]); + }); + + it("migrates legacy queued work to interrupted instead of replaying it", async () => { + const directory = await mkdtemp(join(tmpdir(), "convera-agent-host-")); + temporaryDirectories.push(directory); + const path = join(directory, "jobs.json"); + const current = job("1", "queued"); + const v1 = { + id: current.id, + channelId: current.channelId, + conversationId: current.conversationId, + triggerMessageId: current.triggerMessageId, + agentMemberId: current.agentMemberId, + chain: current.chain, + status: current.status, + attempts: current.attempts, + createdAt: current.createdAt, + updatedAt: current.updatedAt, + }; + await writeFile( + path, + JSON.stringify({ schemaVersion: 1, jobs: [v1] }), + "utf8", + ); + const repository = new JsonAgentHostJobRepository({ path }); + + expect(await repository.list()).toEqual([ + expect.objectContaining({ + id: "1", + status: "interrupted", + contextMessageIds: ["message-1"], + error: expect.stringContaining("predates frozen offer context"), + }), + ]); + }); +}); diff --git a/packages/app/src/electron/agent-host/repository.ts b/packages/app/src/electron/agent-host/repository.ts new file mode 100644 index 00000000..b185e7e1 --- /dev/null +++ b/packages/app/src/electron/agent-host/repository.ts @@ -0,0 +1,192 @@ +import { AtomicJsonFile } from "../memory/json-file"; +import { SerialTaskQueue } from "../memory/serial-queue"; +import type { AgentHostJob } from "@/shared/types/agent-host"; +import { z } from "zod"; + +export interface AgentHostJobRepository { + list(): Promise; + put(job: AgentHostJob): Promise; +} + +export const DEFAULT_MAX_TERMINAL_AGENT_JOBS = 500; + +const chainSchema = z.object({ + hops: z.number().int().min(0), + invoked: z.array(z.string().min(1)), +}); + +const statusSchema = z.enum([ + "queued", + "running", + "completed", + "failed", + "cancelled", + "interrupted", +]); + +const legacyJobSchema = z.object({ + id: z.string().min(1), + channelId: z.string().min(1), + conversationId: z.string().min(1), + triggerMessageId: z.string().min(1), + agentMemberId: z.string().min(1), + chain: chainSchema, + status: statusSchema, + attempts: z.number().int().min(0), + requestId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + error: z.string().optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), +}); + +const jobSchema = legacyJobSchema.extend({ + contextMessageIds: z.array(z.string().min(1)).min(1).max(500), + mode: z.enum(["open-floor", "direct"]), + offeredAgentMemberIds: z.array(z.string().min(1)).max(16), + agentId: z.string().min(1), +}); + +const legacyStateSchema = z.object({ + schemaVersion: z.literal(1), + jobs: z.array(legacyJobSchema), +}); + +const stateSchema = z.object({ + schemaVersion: z.literal(2), + jobs: z.array(jobSchema), +}); + +type LegacyJob = z.infer; + +function migrateLegacyJob(job: LegacyJob): AgentHostJob { + const incompatible = job.status === "queued" || job.status === "running"; + return { + ...job, + agentId: job.agentMemberId.startsWith("agent:") + ? job.agentMemberId.slice("agent:".length) + : job.agentMemberId, + contextMessageIds: [job.triggerMessageId], + mode: "direct", + offeredAgentMemberIds: [job.agentMemberId], + status: incompatible ? "interrupted" : job.status, + error: incompatible + ? "This queued Agent Host job predates frozen offer context and was not replayed." + : job.error, + completedAt: incompatible + ? (job.completedAt ?? job.updatedAt) + : job.completedAt, + }; +} + +function prune(jobs: AgentHostJob[], limit: number): AgentHostJob[] { + const terminal = jobs + .filter((job) => + ["completed", "failed", "cancelled", "interrupted"].includes(job.status), + ) + .sort( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || + left.id.localeCompare(right.id), + ); + const excess = terminal.length - limit; + if (excess <= 0) return jobs; + const removed = new Set(terminal.slice(0, excess).map((job) => job.id)); + return jobs.filter((job) => !removed.has(job.id)); +} + +export class InMemoryAgentHostJobRepository implements AgentHostJobRepository { + private readonly jobs = new Map(); + + constructor(initial: AgentHostJob[] = []) { + for (const job of initial) this.jobs.set(job.id, structuredClone(job)); + } + + async list(): Promise { + return [...this.jobs.values()].map((job) => structuredClone(job)); + } + + async put(job: AgentHostJob): Promise { + this.jobs.set(job.id, structuredClone(job)); + } +} + +export class JsonAgentHostJobRepository implements AgentHostJobRepository { + private readonly file: AtomicJsonFile; + private readonly writes = new SerialTaskQueue(); + private readonly maxTerminalJobs: number; + + constructor(options: { path: string; maxTerminalJobs?: number }) { + this.file = new AtomicJsonFile(options.path); + this.maxTerminalJobs = + options.maxTerminalJobs ?? DEFAULT_MAX_TERMINAL_AGENT_JOBS; + if (!Number.isInteger(this.maxTerminalJobs) || this.maxTerminalJobs < 0) { + throw new RangeError("maxTerminalJobs must be a non-negative integer."); + } + } + + private async readState(): Promise<{ + state: { + schemaVersion: 2; + jobs: AgentHostJob[]; + }; + migrated: boolean; + }> { + const value = await this.file.read(); + if (value === undefined) { + return { state: { schemaVersion: 2, jobs: [] }, migrated: false }; + } + const version = z.object({ schemaVersion: z.number() }).parse(value); + if (version.schemaVersion === 1) { + const legacy = legacyStateSchema.parse(value); + return { + state: { + schemaVersion: 2, + jobs: legacy.jobs.map(migrateLegacyJob), + }, + migrated: true, + }; + } + return { + state: stateSchema.parse(value) as { + schemaVersion: 2; + jobs: AgentHostJob[]; + }, + migrated: false, + }; + } + + private async writeState(state: { + schemaVersion: 2; + jobs: AgentHostJob[]; + }): Promise { + await this.file.write(stateSchema.parse(state)); + } + + async list(): Promise { + return this.writes.run(async () => { + const { state, migrated } = await this.readState(); + const jobs = prune(state.jobs, this.maxTerminalJobs); + if (migrated || jobs.length !== state.jobs.length) { + await this.writeState({ schemaVersion: 2, jobs }); + } + return structuredClone(jobs); + }); + } + + async put(job: AgentHostJob): Promise { + await this.writes.run(async () => { + const validated = jobSchema.parse(job) as AgentHostJob; + const { state } = await this.readState(); + const index = state.jobs.findIndex( + (candidate) => candidate.id === validated.id, + ); + if (index === -1) state.jobs.push(structuredClone(validated)); + else state.jobs[index] = structuredClone(validated); + state.jobs = prune(state.jobs, this.maxTerminalJobs); + await this.writeState(state); + }); + } +} diff --git a/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts b/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts index 7e7d814f..e608c37a 100644 --- a/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts +++ b/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts @@ -47,6 +47,15 @@ function fakeRenderer(visibleChannelIds: string[]) { }), }; } + if (query.kind === "send_message") { + return { + value: JSON.stringify({ + ok: true, + kind: "send_message", + messageId: "posted-1", + }), + }; + } return { value: JSON.stringify({ ok: true, @@ -92,6 +101,30 @@ describe("workspace perception tools", () => { ]); }); + it("passes an optional reply target through the native send_message tool", async () => { + const renderer = fakeRenderer(["joined"]); + const tools = createWorkspacePerceptionTools({ + viewerMemberId: "agent:fizz", + requestInteraction: renderer.requestInteraction, + }); + + await byName(tools, "send_message").execute({ + channel_id: "joined", + content: "Following up.", + reply_to_message_id: "message-7", + }); + + expect(renderer.queries).toEqual([ + { + kind: "send_message", + viewerMemberId: "agent:fizz", + channelId: "joined", + content: "Following up.", + replyToMessageId: "message-7", + }, + ]); + }); + it("surfaces joined and not-joined channels", async () => { const renderer = fakeRenderer(["joined", "visible"]); const tools = createWorkspacePerceptionTools({ diff --git a/packages/app/src/electron/ai/workspace-tools.ts b/packages/app/src/electron/ai/workspace-tools.ts index fc2eb018..b3033ae6 100644 --- a/packages/app/src/electron/ai/workspace-tools.ts +++ b/packages/app/src/electron/ai/workspace-tools.ts @@ -65,6 +65,15 @@ const sendMessageSchema = z.object({ .min(1) .max(WORKSPACE_MESSAGE_CONTENT_MAX) .describe("What to say, as you would type it into the channel."), + reply_to_message_id: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe( + "Message id from read_channel when this message directly answers it.", + ), }); const DESCRIPTIONS = { @@ -73,7 +82,7 @@ const DESCRIPTIONS = { read_channel: "Read one channel's roster and its most recent messages. Use it to catch up on a room — including a visible room you have not joined — before answering or deciding what to remember. Returns oldest-first messages and flags when older history was cut.", send_message: - "Say something in a channel. This is how you speak: nothing you write in your own reasoning reaches anyone until you call this. Post only when you have something worth adding — staying quiet is a normal and complete answer, and a room where everyone answers everything is noise. You may post to any channel you can see, not only the one you were addressed in.", + "Say something in a channel. This is how you speak: nothing you write in your own reasoning reaches anyone until you call this. When answering a particular message returned by read_channel, pass its id as reply_to_message_id. Post only when you have something worth adding — staying quiet is a normal and complete answer, and a room where everyone answers everything is noise. You may post to any channel you can see, not only the one you were addressed in.", } as const; function schemaOf(shape: ZodRawShape): Record { @@ -212,6 +221,9 @@ export function createWorkspacePerceptionTools( viewerMemberId: options.viewerMemberId, channelId: input.channel_id as string, content: input.content as string, + ...(input.reply_to_message_id + ? { replyToMessageId: input.reply_to_message_id as string } + : {}), }), ), perceptionTool( diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index 3b030a7b..0dfaed32 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -18,6 +18,10 @@ import { createElectronMemoryIntegration, type MemoryIntegrationCoordinator, } from "@/electron/memory"; +import { AgentHost } from "@/electron/agent-host/host"; +import { JsonAgentHostJobRepository } from "@/electron/agent-host/repository"; +import { AgentHostRendererBridge } from "@/electron/agent-host/renderer-bridge"; +import { LocalAiAgentHostExecutor } from "@/electron/agent-host/executor"; import { getCurrentShortcut } from "@/electro-bridge/ipc/ipc-handlers"; @@ -46,6 +50,9 @@ import { const logger = getLogger("main-process"); let localAIRuntime: LocalAiRuntime | undefined; let memoryCoordinator: MemoryIntegrationCoordinator | undefined; +let agentHost: AgentHost | undefined; +let agentHostBridge: AgentHostRendererBridge | undefined; +let unsubscribeAgentHost: (() => void) | undefined; let webBridge: WebBridgeHandle | undefined; let localAICleanup: Promise | undefined; let quitAfterCleanup = false; @@ -53,6 +60,11 @@ let quitAfterCleanup = false; function cleanupLocalAI(): Promise { if (localAICleanup) return localAICleanup; localAICleanup = (async () => { + await agentHost?.dispose().catch((error) => { + logger.error("Agent Host cleanup failed:", error); + }); + unsubscribeAgentHost?.(); + agentHostBridge?.dispose(); await webBridge?.close().catch((error) => { logger.error("Web bridge cleanup failed:", error); }); @@ -132,6 +144,9 @@ app.whenReady().then(async () => { const sessionRepository = new JsonSessionStateRepository({ path: join(userDataPath, "local-ai-runtime-state.json"), }); + agentHostBridge = new AgentHostRendererBridge( + () => getMainWindow()?.webContents, + ); memoryCoordinator = createElectronMemoryIntegration({ userDataPath, workingDirectory: process.cwd(), @@ -174,6 +189,17 @@ app.whenReady().then(async () => { ? mcpToolCall(toolName, input) : callTool(serverName, toolName, input), }); + agentHost = new AgentHost({ + repository: new JsonAgentHostJobRepository({ + path: join(userDataPath, "agent-host-jobs.json"), + }), + executor: new LocalAiAgentHostExecutor(localAIRuntime, agentHostBridge), + startPaused: true, + }); + unsubscribeAgentHost = agentHost.subscribe((event) => + agentHostBridge?.emit(event), + ); + await agentHost.initialize(); // Initialize MCP Hub asynchronously but don't block startup initializeMCPHub() @@ -204,6 +230,8 @@ app.whenReady().then(async () => { mainWindow: () => getMainWindow(), registerGlobalShortcuts, localAIRuntime, + agentHost, + agentHostBridge, ipc: recordingIPC, extraLocalAISenders: () => (webBridge?.senders() ?? []).map((sender) => sender as never), diff --git a/packages/app/src/preload.ts b/packages/app/src/preload.ts index b120e535..ed76fd34 100644 --- a/packages/app/src/preload.ts +++ b/packages/app/src/preload.ts @@ -5,6 +5,7 @@ import { createElectronAPI } from "./electro-bridge/ipc/listeners-register"; import { exposeLoggerContext } from "./electro-bridge/ipc/logger-context"; import { exposeLocalAIContext } from "./electro-bridge/ipc/local-ai-context"; import { exposeMCPContext } from "./electro-bridge/ipc/mcp-context"; +import { createAgentHostAPI } from "./electro-bridge/ipc/agent-host-api"; // SOURCE(Sma1lboy): https://www.electronjs.org/docs/latest/tutorial/process-model // expose electronAPI to renderer process @@ -35,6 +36,7 @@ exposeEnvContext(); // Expose local AI API to renderer process exposeLocalAIContext(); +contextBridge.exposeInMainWorld("agentHost", createAgentHostAPI(ipcRenderer)); // Expose Platform API to renderer process contextBridge.exposeInMainWorld("platformAPI", { diff --git a/packages/app/src/renderer/components/agent-host/AgentHostRuntime.tsx b/packages/app/src/renderer/components/agent-host/AgentHostRuntime.tsx new file mode 100644 index 00000000..78454e37 --- /dev/null +++ b/packages/app/src/renderer/components/agent-host/AgentHostRuntime.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { RendererAgentHostService } from "@/renderer/libs/agent-host-service"; + +export function AgentHostRuntime() { + useEffect(() => { + const service = new RendererAgentHostService(); + service.start(); + return () => service.dispose(); + }, []); + return null; +} diff --git a/packages/app/src/renderer/components/chat/AgentContextPanel.tsx b/packages/app/src/renderer/components/chat/AgentContextPanel.tsx new file mode 100644 index 00000000..586d961a --- /dev/null +++ b/packages/app/src/renderer/components/chat/AgentContextPanel.tsx @@ -0,0 +1,325 @@ +import { + inspectAgentDMContext, + type AgentContextInspection, +} from "@/renderer/libs/agent-context-inspector"; +import { cn } from "@/renderer/libs/utils/tailwind"; +import { motion } from "framer-motion"; +import { + Activity, + Brain, + Eye, + Loader2, + LockKeyhole, + Wrench, + X, +} from "lucide-react"; +import React, { useEffect, useState } from "react"; + +function Section({ + icon: Icon, + title, + children, +}: { + icon: typeof Brain; + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +/** + * Private, out-of-band inspection of the context Convera can account for. + * Nothing rendered here is appended to the DM transcript or exposed as an + * agent tool. Home owns whether the panel is mounted; the loader itself still + * rejects non-DM channels so a public header cannot accidentally reuse it. + */ +export function AgentContextPanel({ + channelId, + onClose, +}: { + channelId: string; + onClose: () => void; +}) { + const [inspection, setInspection] = useState( + null, + ); + const [error, setError] = useState(null); + + useEffect(() => { + let current = true; + setInspection(null); + setError(null); + void inspectAgentDMContext(channelId) + .then((result) => { + if (current) setInspection(result); + }) + .catch((reason: unknown) => { + if (!current) return; + setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => { + current = false; + }; + }, [channelId]); + + return ( + +
+
+

+ {inspection + ? `${inspection.agent.name}'s context` + : "Agent context"} +

+

+ Private inspector · never posted to the conversation +

+
+ +
+ +
+ {!inspection && !error && ( +
+ + Reading renderer-owned context… +
+ )} + {error && ( +
+ {error} +
+ )} + + {inspection && ( + <> +
+

{inspection.agent.name}

+

+ {inspection.agent.description} +

+

+ {inspection.agent.providerId ?? "Conversation provider"} + {inspection.agent.modelId + ? ` · ${inspection.agent.modelId}` + : " · conversation model"} +

+
+ +
+

+ This prompt defines a new or rotated native session. On resume, + the provider retains its own opaque history. +

+
+ + Effective system prompt + +
+                  {inspection.injected.effectiveSystemPrompt}
+                
+
+
+ + Renderer transcript projection ·{" "} + {inspection.injected.transcriptProjection.length} + +
+ {inspection.injected.transcriptProjection.length === 0 ? ( + No DM messages are in the bootstrap view yet. + ) : ( + inspection.injected.transcriptProjection.map( + (message, index) => ( +
+ + {message.role} + +

+ {message.content} +

+
+ ), + ) + )} +
+
+
+ +
+

+ These rooms are discoverable through workspace tools; their full + transcripts are not preloaded into this DM. +

+
+ {inspection.available.visibleChannels.map((channel) => ( + + {channel.kind === "dm" ? "DM" : "#"} {channel.name} + + ))} +
+
+ {inspection.available.workspaceTools.map((tool) => ( + + workspace:{tool} + + ))} +
+
+ +
+

+ Configuration is not the effective runtime catalog; connection + state and sandbox policy are resolved when a turn runs. +

+ {inspection.available.configuredMcpServerIds.length ? ( +
+ {inspection.available.configuredMcpServerIds.map((server) => ( + + {server} + + ))} +
+ ) : ( + No agent-specific MCP servers are configured. + )} + {inspection.available.configuredDisabledTools.length > 0 && ( +

+ Disabled:{" "} + {inspection.available.configuredDisabledTools + .map((tool) => `${tool.mcpName}:${tool.toolName}`) + .join(", ")} +

+ )} + {inspection.available.effectiveToolCatalog.length > 0 && ( +
+ + Connected runtime tools + +
+ {inspection.available.effectiveToolCatalog.map((group) => ( +

+ + {group.serverName}: + {" "} + {group.toolNames.join(", ") || "No tools"} +

+ ))} +
+
+ )} +
+ +
+ {inspection.runtime.conversation ? ( +
+

+ Transcript v + {inspection.runtime.conversation.transcriptVersion} + {" · "}memory v + {inspection.runtime.conversation.memoryVersion} +

+ {inspection.runtime.conversation.providers + .filter( + (binding) => + binding.actorId === inspection.agent.memberId, + ) + .map((binding) => ( +

+ {binding.providerId} + {binding.modelId ? ` · ${binding.modelId}` : ""} + {binding.stale ? " · stale" : " · resumable"} +

+ ))} +
+ ) : ( + No provider session is bound to this DM yet. + )} + {inspection.runtime.memory ? ( +

+ Memory {inspection.runtime.memory.health} + {inspection.runtime.memory.memoryVersion !== undefined + ? ` · v${inspection.runtime.memory.memoryVersion}` + : ""} + {inspection.runtime.memory.detail + ? ` · ${inspection.runtime.memory.detail}` + : ""} +

+ ) : ( + Durable memory status is not available. + )} + {inspection.runtime.errors.map((runtimeError) => ( +

+ {runtimeError} +

+ ))} +
+ +
+
+ {inspection.opaque.map((item) => ( +
+

{item.label}

+

+ {item.detail} +

+
+ ))} +
+
+ + )} +
+
+ ); +} diff --git a/packages/app/src/renderer/components/chat/ChannelHeader.tsx b/packages/app/src/renderer/components/chat/ChannelHeader.tsx index c0341730..ef48c721 100644 --- a/packages/app/src/renderer/components/chat/ChannelHeader.tsx +++ b/packages/app/src/renderer/components/chat/ChannelHeader.tsx @@ -10,15 +10,27 @@ import { } from "@/renderer/libs/stores/member-store"; import { MemberAvatar } from "@/renderer/components/common/member-avatar"; import { cn } from "@/renderer/libs/utils/tailwind"; +import { useAgentHostJobs } from "@/renderer/libs/hooks/use-agent-host-jobs"; import type { Member } from "@/shared/types/workspace"; import { AnimatePresence, motion } from "framer-motion"; -import { Hash, Lock, Plus, Star, X } from "lucide-react"; +import { + Brain, + Hash, + LoaderCircle, + Lock, + Plus, + Square, + Star, + X, +} from "lucide-react"; import React, { useMemo, useState } from "react"; interface ChannelHeaderProps { channel: Channel; rosterOpen: boolean; onToggleRoster: () => void; + contextOpen?: boolean; + onToggleContext?: () => void; } /** @@ -30,8 +42,14 @@ export function ChannelHeader({ channel, rosterOpen, onToggleRoster, + contextOpen, + onToggleContext, }: ChannelHeaderProps) { const allMembers = useMembers(); + const { jobs, activeJobs, error: hostError } = useAgentHostJobs(channel.id); + const latestFailure = [...jobs] + .reverse() + .find((job) => job.status === "failed" || job.status === "interrupted"); const Icon = channel.isPrivate ? Lock : Hash; const members = useMemo(() => { @@ -47,10 +65,50 @@ export function ChannelHeader({

{channel.name}

+ {activeJobs.length > 0 && ( + + + {activeJobs.length} working + + )} + {hostError && ( + + Agent Host unavailable + + )} + {!hostError && activeJobs.length === 0 && latestFailure?.error && ( + + Agent error: {latestFailure.error} + + )} + {channel.kind === "dm" && onToggleContext && ( + + )} + )} + {/* Faded rather than hidden: display:none would put these out of reach of the keyboard entirely. */} {member.kind === "agent" && !isDefault && ( diff --git a/packages/app/src/renderer/components/chat/TypingIndicator.tsx b/packages/app/src/renderer/components/chat/TypingIndicator.tsx index e858bcc8..42279722 100644 --- a/packages/app/src/renderer/components/chat/TypingIndicator.tsx +++ b/packages/app/src/renderer/components/chat/TypingIndicator.tsx @@ -11,11 +11,23 @@ import React from "react"; * composes. It deliberately does not occupy a message row — it is a hint that * someone is about to speak, not a claim that they did. */ -export function TypingIndicator() { +export function TypingIndicator({ + conversationId, +}: { + conversationId?: string | null; +}) { const typing = useTypingStore((state) => state.typing); const members = useMembers(); - const names = [...new Set(Object.values(typing))]; + const names = conversationId + ? [ + ...new Set( + Object.values(typing) + .filter((entry) => entry.conversationId === conversationId) + .map((entry) => entry.memberId), + ), + ] + : []; if (names.length === 0) return null; const byId = new Map((members ?? []).map((member) => [member.id, member])); diff --git a/packages/app/src/renderer/components/chat/input/chat-input.tsx b/packages/app/src/renderer/components/chat/input/chat-input.tsx index 85f000df..8e424b50 100644 --- a/packages/app/src/renderer/components/chat/input/chat-input.tsx +++ b/packages/app/src/renderer/components/chat/input/chat-input.tsx @@ -3,6 +3,8 @@ import { activeMentionQuery } from "@/renderer/libs/mention-parser"; import { useChatContext } from "@/renderer/libs/stores/chat-store"; import { useMembers } from "@/renderer/libs/stores/member-store"; import type { Member } from "@/shared/types/workspace"; +import { resolveSenderName } from "@/renderer/libs/chat-labels"; +import { X } from "lucide-react"; import React, { forwardRef, useCallback, @@ -54,7 +56,22 @@ const ChatInput = forwardRef( stopGeneration, selectedContent, rejectSelectedContent, + messages, + replyTargetId, + setReplyTarget, } = useChatContext(); + const replyTarget = replyTargetId + ? messages.find((message) => message.id === replyTargetId) + : undefined; + const replySender = replyTarget?.senderId + ? members.find((member) => member.id === replyTarget.senderId) + : undefined; + const replySenderName = replyTarget + ? resolveSenderName({ + senderName: replySender?.name, + isUser: replyTarget.role === "user", + }) + : null; // Watch for speech input changes and update editor directly useEffect(() => { @@ -155,6 +172,29 @@ const ChatInput = forwardRef( {/* No overflow clipping here: the editor scrolls itself, and the mention popup floats above the box. */}
+ {replyTarget && ( +
+
+

+ Replying to {replySenderName} +

+

+ {typeof replyTarget.content === "string" + ? replyTarget.content + : ""} +

+
+ +
+ )} ( onSubmit={handleSubmit} autoFocus={true} onCaretChange={handleCaretChange} - onKeyDownCapture={(event) => - autocompleteRef.current?.handleKeyDown(event) ?? false - } + onKeyDownCapture={(event) => { + if (event.key === "Escape" && replyTargetId) { + setReplyTarget(null); + return true; + } + return autocompleteRef.current?.handleKeyDown(event) ?? false; + }} />
diff --git a/packages/app/src/renderer/components/chat/message/chat-content.tsx b/packages/app/src/renderer/components/chat/message/chat-content.tsx index 42265b53..5d566efa 100644 --- a/packages/app/src/renderer/components/chat/message/chat-content.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-content.tsx @@ -25,6 +25,7 @@ interface ChatContentProps { isLoading: boolean; onEditMessage: (message: UIMessage, newContent: string) => void; onRegenerateMessage: (message: UIMessage) => void; + onReplyToMessage: (message: UIMessage) => void; onBranchFromMessage: (messageIndex: number) => void; agentChanged?: boolean; onRegenerateWithNewAgent?: () => void; @@ -40,6 +41,7 @@ export default function ChatContent({ isLoading, onEditMessage, onRegenerateMessage, + onReplyToMessage, onBranchFromMessage, showReactions, }: ChatContentProps) { @@ -347,6 +349,9 @@ export default function ChatContent({ // Note: This hook must be called before any conditional returns const renderMessages = useCallback(() => { const membersById = new Map((members ?? []).map((m) => [m.id, m])); + const messagesById = new Map( + messages.map((message) => [message.id, message]), + ); return messages.map((message, index) => { const isLastMessage = index === messages.length - 1; @@ -354,6 +359,7 @@ export default function ChatContent({ // Without senderId, role is the identity — two consecutive assistant // rows are the same speaker, same as two consecutive user rows. const isGrouped = + !message.replyToMessageId && !!previous && (message.senderId ? previous.senderId === message.senderId @@ -363,6 +369,26 @@ export default function ChatContent({ ); const isEditing = editingMessageId === message.id; const isCopied = copiedMessageId === message.id ? true : false; + const replyParent = message.replyToMessageId + ? messagesById.get(message.replyToMessageId) + : undefined; + const replySender = replyParent?.senderId + ? membersById.get(replyParent.senderId) + : undefined; + const replyTarget = replyParent + ? { + id: replyParent.id, + senderName: resolveSenderName({ + senderName: replySender?.name, + isUser: replyParent.role === "user", + agentName, + }), + content: + typeof replyParent.content === "string" + ? replyParent.content + : "", + } + : null; // Extract selected content for user messages let selectedContent: SelectedContent | null = null; @@ -414,7 +440,16 @@ export default function ChatContent({ onEditContentChange={setEditedContent} onCopy={() => handleCopyContent(message.content || "", message.id)} onRegenerate={() => handleRegenerateWithLoading(message)} + onReply={() => onReplyToMessage(message)} onBranch={onBranchFromMessage} + replyTarget={replyTarget} + onOpenReplyTarget={() => { + if (!replyTarget) return; + const target = document.querySelector( + `[data-message-id="${CSS.escape(replyTarget.id)}"]`, + ); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + }} renderContent={content} /> ); @@ -434,6 +469,7 @@ export default function ChatContent({ handleCopyContent, handleRegenerateWithLoading, onBranchFromMessage, + onReplyToMessage, renderToolCalls, renderMessageContent, isLoading, diff --git a/packages/app/src/renderer/components/chat/message/message-row.tsx b/packages/app/src/renderer/components/chat/message/message-row.tsx index 4c610e3d..ab40bcae 100644 --- a/packages/app/src/renderer/components/chat/message/message-row.tsx +++ b/packages/app/src/renderer/components/chat/message/message-row.tsx @@ -19,6 +19,7 @@ import { Edit, GitBranch, RefreshCw, + Reply, SmilePlus, } from "lucide-react"; import React, { memo } from "react"; @@ -56,7 +57,14 @@ export interface MessageRowProps { onEditContentChange: (content: string) => void; onCopy: () => void; onRegenerate: () => void; + onReply: () => void; onBranch: (messageIndex: number) => void; + replyTarget?: { + id: string; + senderName: string; + content: string; + } | null; + onOpenReplyTarget: () => void; renderContent: React.ReactNode; } @@ -157,7 +165,10 @@ const MessageRow = memo( onEditContentChange, onCopy, onRegenerate, + onReply, onBranch, + replyTarget, + onOpenReplyTarget, renderContent, }: MessageRowProps) => { const isUser = message.role === "user"; @@ -176,6 +187,7 @@ const MessageRow = memo( return ( )} + {message.replyToMessageId && ( + + )} +
{isEditing ? (
@@ -294,6 +328,15 @@ const MessageRow = memo( )} + + -
- + > + {directMessageAgent?.name ?? channel.name} + + +
+ ); + + // A durable DM is reopened from its agent identity. Generic channel rename + // and delete actions would make that stable relationship look disposable. + if (channel.kind === "dm") return item; + + return ( + + {item} setIsRenaming(true)}> diff --git a/packages/app/src/renderer/components/sidebar/WorkspaceSidebar.tsx b/packages/app/src/renderer/components/sidebar/WorkspaceSidebar.tsx index a20c20dd..ffe41bd3 100644 --- a/packages/app/src/renderer/components/sidebar/WorkspaceSidebar.tsx +++ b/packages/app/src/renderer/components/sidebar/WorkspaceSidebar.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { Plus, Settings as SettingsIcon } from "lucide-react"; +import { MessageCircle, Plus, Settings as SettingsIcon } from "lucide-react"; import { useActiveConversations } from "@/renderer/libs/db/hooks"; import { isUnread, @@ -13,10 +13,13 @@ import { useGroups, type Channel, } from "@/renderer/libs/stores/channel-store"; -import { useMember } from "@/renderer/libs/stores/member-store"; +import { useMember, useMembers } from "@/renderer/libs/stores/member-store"; import { LOCAL_HUMAN_MEMBER_ID } from "@/renderer/libs/db"; +import { ensureAgentDM } from "@/renderer/libs/agent-dm"; +import { MemberAvatar } from "@/renderer/components/common/member-avatar"; import { cn } from "@/renderer/libs/utils/tailwind"; import { ConversationItem } from "./ConversationItem"; +import { ChannelItem } from "./ChannelItem"; import { GroupSection } from "./GroupSection"; import { InlineNameInput } from "./InlineNameInput"; import { useWorkspaceUI } from "@/renderer/libs/stores/workspace-ui-context"; @@ -28,6 +31,7 @@ const SECTION_LABEL = export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { const groups = useGroups(); const channels = useChannels(); + const members = useMembers(); const conversations = useActiveConversations(); const { currentConversationId, setCurrentConversation } = useSelectionStore(); const { lastSeen, markSeen } = useUnreadStore(); @@ -38,6 +42,7 @@ export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { } = useWorkspaceUI(); const [isAddingGroup, setIsAddingGroup] = useState(false); const [isAddingFirstChannel, setIsAddingFirstChannel] = useState(false); + const [isStartingDM, setIsStartingDM] = useState(false); const conversationById = useMemo( () => new Map((conversations ?? []).map((c) => [c.id, c])), @@ -60,11 +65,32 @@ export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { setView("chat"); }; - const drag = useChannelDrag(channels ?? []); + const teamChannels = useMemo( + () => (channels ?? []).filter((channel) => channel.kind === "channel"), + [channels], + ); + const directMessages = useMemo( + () => + (channels ?? []) + .filter((channel) => channel.kind === "dm") + .sort((left, right) => left.name.localeCompare(right.name)), + [channels], + ); + const agentsWithoutDM = useMemo(() => { + const existing = new Set( + directMessages.flatMap((channel) => channel.memberIds), + ); + return (members ?? []).filter( + (member) => + member.kind === "agent" && !!member.agentId && !existing.has(member.id), + ); + }, [directMessages, members]); + + const drag = useChannelDrag(teamChannels); const channelsByGroup = useMemo(() => { const byGroup = new Map(); - for (const channel of channels ?? []) { + for (const channel of teamChannels) { const list = byGroup.get(channel.groupId); if (list) list.push(channel); else byGroup.set(channel.groupId, [channel]); @@ -73,7 +99,7 @@ export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); } return byGroup; - }, [channels]); + }, [teamChannels]); // Conversations nobody's channel points at: 1:1 chats, listed under CHATS. const plainConversations = useMemo(() => { @@ -82,13 +108,13 @@ export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { }, [conversations, channels]); const selectChannel = (channel: Channel) => select(channel.conversationId); - const hasChannels = (channels?.length ?? 0) > 0; + const hasChannels = teamChannels.length > 0; // Unread indicators on the INACTIVE tab, so nothing gets lost while hidden. - const teamsHaveUnread = (channels ?? []).some(isChannelUnread); - const chatsHaveUnread = plainConversations.some((c) => - isUnread(lastSeen, c.id, c.updatedAt), - ); + const teamsHaveUnread = teamChannels.some(isChannelUnread); + const chatsHaveUnread = + directMessages.some(isChannelUnread) || + plainConversations.some((c) => isUnread(lastSeen, c.id, c.updatedAt)); return (
@@ -209,7 +235,74 @@ export function WorkspaceSidebar({ onNewChat }: { onNewChat?: () => void }) { )} {activeTab === "chats" && ( -
+
+
+ Direct messages + +
+ + {isStartingDM && ( +
+ {agentsWithoutDM.length > 0 ? ( + agentsWithoutDM.map((member) => ( + + )) + ) : ( +

+ {directMessages.length > 0 + ? "Every agent already has a direct message." + : "Hire an agent before starting a direct message."} +

+ )} +
+ )} + + {directMessages.length > 0 && ( +
+ {directMessages.map((channel) => ( + selectChannel(channel)} + /> + ))} +
+ )} +
+ )} + {activeTab === "chats" && plainConversations.length > 0 && ( +
Recent
)} diff --git a/packages/app/src/renderer/layouts/base-layout.tsx b/packages/app/src/renderer/layouts/base-layout.tsx index 1da134f5..8c431fcb 100644 --- a/packages/app/src/renderer/layouts/base-layout.tsx +++ b/packages/app/src/renderer/layouts/base-layout.tsx @@ -1,5 +1,6 @@ import React from "react"; import { ChatProvider } from "../libs/stores/chat-store"; +import { AgentHostRuntime } from "../components/agent-host/AgentHostRuntime"; /** * Base layout component that provides the main application structure @@ -11,6 +12,7 @@ export default function BaseLayout({ }) { return (
+ {children}
); diff --git a/packages/app/src/renderer/libs/__tests__/agent-speech.test.ts b/packages/app/src/renderer/libs/__tests__/agent-speech.test.ts index 9e1fb66e..9eeda50b 100644 --- a/packages/app/src/renderer/libs/__tests__/agent-speech.test.ts +++ b/packages/app/src/renderer/libs/__tests__/agent-speech.test.ts @@ -1,5 +1,6 @@ import "fake-indexeddb/auto"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAgentHostAPI } from "@/shared/types/agent-host"; import { installAgentSpeech } from "../agent-speech"; import { db, LOCAL_HUMAN_MEMBER_ID, memberIdForAgent } from "../db"; import { resolveWorkspaceQuery } from "../workspace-perception"; @@ -94,6 +95,87 @@ describe("agent speech", () => { expect(posted.mentions).toEqual([LOCAL_HUMAN_MEMBER_ID]); }); + it("persists the same-channel message an agent directly replied to", async () => { + const { channelId, conversationId } = await seedRoom(); + await db.messages.add({ + id: "human-question", + conversationId, + role: "user", + content: "Can you own this?", + senderId: LOCAL_HUMAN_MEMBER_ID, + status: "completed", + createdAt: new Date(), + }); + + const result = await resolveWorkspaceQuery({ + kind: "send_message", + viewerMemberId: sageMemberId, + channelId, + content: "Yes, I have it.", + replyToMessageId: "human-question", + }); + + if (!result.ok || result.kind !== "send_message") throw new Error("bad"); + expect(await db.messages.get(result.messageId)).toMatchObject({ + senderId: sageMemberId, + replyToMessageId: "human-question", + content: "Yes, I have it.", + }); + }); + + it("durably hands an agent mention to the next colleague", async () => { + const patchMemberId = memberIdForAgent("a-patch"); + const { channelId, conversationId } = await seedRoom(); + await db.members.put({ + id: patchMemberId, + workspaceId: "personal", + kind: "agent", + name: "Patch", + avatar: null, + agentId: "a-patch", + status: "idle", + }); + const channel = await db.channels.get(channelId); + await db.channels.update(channelId, { + memberIds: [...channel!.memberIds, patchMemberId], + }); + await db.messages.add({ + id: "human-trigger", + conversationId, + role: "user", + content: "Please coordinate", + senderId: LOCAL_HUMAN_MEMBER_ID, + createdAt: new Date(1), + }); + const enqueue = vi.fn(async () => ({ success: true, jobs: [] })); + Object.assign(globalThis, { + window: { agentHost: { enqueue } as unknown as IAgentHostAPI }, + }); + + await resolveWorkspaceQuery({ + kind: "send_message", + viewerMemberId: sageMemberId, + channelId, + content: "@Patch please take the implementation", + agentHost: { + jobId: "job-sage", + triggerMessageId: "human-trigger", + contextMessageIds: ["human-trigger"], + chain: { hops: 0, invoked: [sageMemberId] }, + }, + }); + + expect(enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + triggerMessageId: expect.any(String), + mode: "direct", + offeredAgentMemberIds: [patchMemberId], + targets: [{ agentId: "a-patch", memberId: patchMemberId }], + chain: { hops: 1, invoked: [sageMemberId, patchMemberId] }, + }), + ); + }); + it("leaves no trace when the agent says nothing", async () => { // The whole point: silence is the absence of a call, not an empty bubble // that has to be cleaned up afterwards. diff --git a/packages/app/src/renderer/libs/__tests__/workspace-perception.test.ts b/packages/app/src/renderer/libs/__tests__/workspace-perception.test.ts index 5ecaa1da..cf068c96 100644 --- a/packages/app/src/renderer/libs/__tests__/workspace-perception.test.ts +++ b/packages/app/src/renderer/libs/__tests__/workspace-perception.test.ts @@ -224,6 +224,39 @@ describe("read_channel", () => { expect(result.channel.truncated).toBe(true); }); + it("describes a reply even when its parent is outside the read window", async () => { + await db.messages.put({ + ...message( + "latest-reply", + "conversation-joined", + AGENT, + "I will take it.", + 8, + ), + replyToMessageId: "m1", + }); + + const result = await resolveWorkspaceQuery({ + kind: "read_channel", + viewerMemberId: AGENT, + channelId: "joined", + limit: 1, + }); + + if (!result.ok || result.kind !== "read_channel") throw new Error("bad"); + expect(result.channel.messages).toEqual([ + expect.objectContaining({ + id: "latest-reply", + replyTo: { + messageId: "m1", + senderId: HUMAN, + senderName: "You", + content: "Kickoff is Monday.", + }, + }), + ]); + }); + it("trims a transcript that would overflow the transport budget", async () => { const body = "x".repeat(1_900); await db.messages.bulkPut( @@ -253,6 +286,53 @@ describe("read_channel", () => { }); describe("send_message", () => { + it("passes a valid same-channel reply target to the writer", async () => { + const handler = vi.fn(async () => ({ + ok: true as const, + kind: "send_message" as const, + messageId: "created-reply", + })); + registerWorkspaceSendMessage(handler); + + const result = await resolveWorkspaceQuery({ + kind: "send_message", + viewerMemberId: AGENT, + channelId: "joined", + content: "Following up.", + replyToMessageId: "m1", + }); + + expect(result).toMatchObject({ ok: true, messageId: "created-reply" }); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: "m1" }), + ); + registerWorkspaceSendMessage(undefined); + }); + + it("rejects a cross-channel reply target before the writer runs", async () => { + const handler = vi.fn(async () => ({ + ok: true as const, + kind: "send_message" as const, + messageId: "should-not-exist", + })); + registerWorkspaceSendMessage(handler); + + const result = await resolveWorkspaceQuery({ + kind: "send_message", + viewerMemberId: AGENT, + channelId: "joined", + content: "Wrong room.", + replyToMessageId: "m3", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "REPLY_TARGET_NOT_FOUND" }, + }); + expect(handler).not.toHaveBeenCalled(); + registerWorkspaceSendMessage(undefined); + }); + it("delegates a write to any channel the agent can see, joined or not", async () => { const handler = vi.fn(async () => ({ ok: true as const, diff --git a/packages/app/src/renderer/libs/agent-context-inspector.test.ts b/packages/app/src/renderer/libs/agent-context-inspector.test.ts new file mode 100644 index 00000000..3cecbfdd --- /dev/null +++ b/packages/app/src/renderer/libs/agent-context-inspector.test.ts @@ -0,0 +1,135 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + db, + LOCAL_HUMAN_MEMBER, + LOCAL_HUMAN_MEMBER_ID, + memberIdForAgent, + type Agent, +} from "./db"; +import { ensureAgentDM } from "./agent-dm"; +import { inspectAgentDMContext } from "./agent-context-inspector"; + +const AGENT: Agent = { + id: "sage", + name: "Sage", + description: "Researcher", + systemPrompt: "Investigate carefully.", + disableToolReferences: [ + { mcpName: "files", toolName: "write", reason: "read only" }, + ], + selectedMCPs: ["files"], + providerId: "codex-cli", + modelId: "gpt-5", + isBuiltIn: false, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("agent context inspector", () => { + beforeEach(async () => { + await db.open(); + await Promise.all([ + db.messages.clear(), + db.channels.clear(), + db.conversations.clear(), + db.members.clear(), + db.agents.clear(), + ]); + await db.members.put(LOCAL_HUMAN_MEMBER); + await db.agents.put(AGENT); + }); + + it("shows the exact renderer-owned prompt, projection and visible rooms", async () => { + const dm = await ensureAgentDM(AGENT.id); + const now = new Date(); + await db.messages.bulkAdd([ + { + id: "human-message", + conversationId: dm.conversationId, + role: "user", + senderId: LOCAL_HUMAN_MEMBER_ID, + content: "What changed?", + createdAt: now, + }, + { + id: "agent-message", + conversationId: dm.conversationId, + role: "assistant", + senderId: memberIdForAgent(AGENT.id), + content: "I will check.", + createdAt: new Date(now.getTime() + 1), + }, + ]); + await db.channels.bulkAdd([ + { + id: "public", + workspaceId: "personal", + groupId: null, + name: "public-room", + kind: "channel", + isPrivate: false, + memberIds: [LOCAL_HUMAN_MEMBER_ID], + conversationId: "public-conversation", + defaultAgentMemberId: null, + createdAt: now, + updatedAt: now, + }, + { + id: "private", + workspaceId: "personal", + groupId: null, + name: "hidden-room", + kind: "channel", + isPrivate: true, + memberIds: [LOCAL_HUMAN_MEMBER_ID], + conversationId: "private-conversation", + defaultAgentMemberId: null, + createdAt: now, + updatedAt: now, + }, + ]); + + const context = await inspectAgentDMContext(dm.channelId); + + expect(context.injected.effectiveSystemPrompt).toContain( + "Investigate carefully.", + ); + expect(context.injected.effectiveSystemPrompt).toContain( + `channel_id: ${dm.channelId}`, + ); + expect(context.injected.transcriptProjection).toEqual([ + { role: "user", content: "You: What changed?" }, + { role: "assistant", content: "I will check." }, + ]); + expect(context.available.visibleChannels.map((room) => room.name)).toEqual([ + "public-room", + "Sage", + ]); + expect(context.available.configuredMcpServerIds).toEqual(["files"]); + expect(context.opaque.map((entry) => entry.label)).toContain( + "Provider-native session history", + ); + }); + + it("refuses to derive private agent context for a public channel", async () => { + const now = new Date(); + await db.channels.add({ + id: "public", + workspaceId: "personal", + groupId: null, + name: "public-room", + kind: "channel", + isPrivate: false, + memberIds: [LOCAL_HUMAN_MEMBER_ID], + conversationId: "public-conversation", + defaultAgentMemberId: null, + createdAt: now, + updatedAt: now, + }); + + await expect(inspectAgentDMContext("public")).rejects.toThrow( + "only inside a private DM", + ); + }); +}); diff --git a/packages/app/src/renderer/libs/agent-context-inspector.ts b/packages/app/src/renderer/libs/agent-context-inspector.ts new file mode 100644 index 00000000..e2957b3f --- /dev/null +++ b/packages/app/src/renderer/libs/agent-context-inspector.ts @@ -0,0 +1,226 @@ +import type { LocalAIMessage } from "@/shared/types/local-ai"; +import type { + LocalAIConversationRuntimeState, + LocalAIMemoryStatus, +} from "@/shared/types/local-ai"; +import type { Channel, Member } from "@/shared/types/workspace"; +import { buildChannelContext, projectFor } from "./agent-projection"; +import { db, LOCAL_HUMAN_MEMBER_ID, type Agent, type Message } from "./db"; +import { canViewChannel } from "./workspace-perception"; + +export interface AgentContextVisibleChannel { + id: string; + name: string; + kind: Channel["kind"]; + isPrivate: boolean; +} + +export interface AgentContextInspection { + channelId: string; + conversationId: string; + agent: Pick< + Agent, + "id" | "name" | "description" | "providerId" | "modelId" + > & { + memberId: string; + }; + injected: { + /** Identity prompt used when a native provider session is created or rotated. */ + identityPrompt: string; + channelContext: string; + effectiveSystemPrompt: string; + /** Renderer-owned recovery/bootstrap view, not provider-owned session history. */ + transcriptProjection: LocalAIMessage[]; + }; + available: { + visibleChannels: AgentContextVisibleChannel[]; + workspaceTools: string[]; + configuredMcpServerIds: string[]; + configuredDisabledTools: Array<{ + mcpName: string; + toolName: string; + reason?: string; + }>; + effectiveToolCatalog: Array<{ + serverName: string; + toolNames: string[]; + }>; + }; + runtime: { + conversation: LocalAIConversationRuntimeState | null; + memory: LocalAIMemoryStatus | null; + errors: string[]; + }; + opaque: Array<{ label: string; detail: string }>; +} + +function projectable(messages: Message[]) { + return messages.map((message) => ({ + id: message.id, + senderId: message.senderId, + role: message.role, + content: message.content, + })); +} + +function agentMember(channel: Channel, members: Member[]): Member | undefined { + const byId = new Map(members.map((member) => [member.id, member])); + const preferred = channel.defaultAgentMemberId + ? byId.get(channel.defaultAgentMemberId) + : undefined; + if (preferred?.kind === "agent") return preferred; + return channel.memberIds + .map((id) => byId.get(id)) + .find((member): member is Member => member?.kind === "agent"); +} + +/** + * Builds the context the renderer can prove from its own authoritative data. + * + * Runtime memory, effective tool resolution and provider-native history live + * in Electron main or the provider. They are called out as opaque instead of + * being guessed from renderer configuration. + */ +export async function inspectAgentDMContext( + channelId: string, +): Promise { + const channel = await db.channels.get(channelId); + if (!channel || channel.kind !== "dm" || !channel.isPrivate) { + throw new Error("Agent context is available only inside a private DM."); + } + if (!channel.memberIds.includes(LOCAL_HUMAN_MEMBER_ID)) { + throw new Error("The local user is not a member of this DM."); + } + + const [memberRows, messages, channels] = await Promise.all([ + db.members.bulkGet(channel.memberIds), + db.messages + .where("conversationId") + .equals(channel.conversationId) + .sortBy("createdAt"), + db.channels.toArray(), + ]); + const room = memberRows.filter( + (member): member is Member => member !== undefined, + ); + const member = agentMember(channel, room); + if (!member?.agentId) { + throw new Error("This DM has no agent participant."); + } + const agent = await db.agents.get(member.agentId); + if (!agent) throw new Error(`Agent ${member.agentId} does not exist.`); + + const channelContext = buildChannelContext( + member, + channel.name, + room, + false, + [], + channel.id, + ); + const identityPrompt = agent.systemPrompt.trim(); + const effectiveSystemPrompt = identityPrompt + ? `${identityPrompt}\n\n${channelContext}` + : channelContext; + const allMembers = await db.members.toArray(); + const runtimeErrors: string[] = []; + let runtimeConversation: LocalAIConversationRuntimeState | null = null; + let memoryStatus: LocalAIMemoryStatus | null = null; + let effectiveToolCatalog: AgentContextInspection["available"]["effectiveToolCatalog"] = + []; + if (typeof window !== "undefined") { + if (window.localAI) { + const [runtimeResult, memoryResult] = await Promise.all([ + window.localAI.getConversationRuntimeState(channel.conversationId), + window.localAI.getMemoryStatus(channel.conversationId), + ]); + if (runtimeResult.success) { + runtimeConversation = runtimeResult.data ?? null; + } else { + runtimeErrors.push( + runtimeResult.error?.message ?? "Runtime state is unavailable.", + ); + } + if (memoryResult.success) { + memoryStatus = memoryResult.data ?? null; + } else { + runtimeErrors.push( + memoryResult.error?.message ?? "Memory status is unavailable.", + ); + } + } + if (window.mcpAPI) { + const toolsResult = await window.mcpAPI.getAllTools(); + if (toolsResult.success) { + effectiveToolCatalog = (toolsResult.data ?? []).map((group) => ({ + serverName: group.serverName, + toolNames: group.tools.map((tool) => tool.name), + })); + } else { + runtimeErrors.push( + toolsResult.error ?? "The effective tool catalog is unavailable.", + ); + } + } + } + + return { + channelId: channel.id, + conversationId: channel.conversationId, + agent: { + id: agent.id, + memberId: member.id, + name: agent.name, + description: agent.description, + providerId: agent.providerId, + modelId: agent.modelId, + }, + injected: { + identityPrompt, + channelContext, + effectiveSystemPrompt, + transcriptProjection: projectFor( + member.id, + projectable(messages), + allMembers, + ), + }, + available: { + visibleChannels: channels + .filter((candidate) => canViewChannel(member.id, candidate)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((candidate) => ({ + id: candidate.id, + name: candidate.name, + kind: candidate.kind, + isPrivate: candidate.isPrivate, + })), + workspaceTools: ["list_channels", "read_channel", "send_message"], + configuredMcpServerIds: [...(agent.selectedMCPs ?? [])], + configuredDisabledTools: [...agent.disableToolReferences], + effectiveToolCatalog, + }, + runtime: { + conversation: runtimeConversation, + memory: memoryStatus, + errors: runtimeErrors, + }, + opaque: [ + { + label: "Provider-native session history", + detail: + "Codex or Claude owns its resumed session history; Convera does not read or mirror it into the channel.", + }, + { + label: "Runtime memory payload", + detail: + "Durable memory is compiled in Electron main. Its health and version can be inspected separately, but renderer configuration is not the payload.", + }, + { + label: "Provider hidden prompt and sandbox internals", + detail: + "The connected tool names are inspectable, but provider-owned hidden instructions and OS sandbox internals are not exposed to the renderer.", + }, + ], + }; +} diff --git a/packages/app/src/renderer/libs/agent-dm.test.ts b/packages/app/src/renderer/libs/agent-dm.test.ts new file mode 100644 index 00000000..bdcddda3 --- /dev/null +++ b/packages/app/src/renderer/libs/agent-dm.test.ts @@ -0,0 +1,122 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { db, LOCAL_HUMAN_MEMBER_ID, memberIdForAgent, type Agent } from "./db"; +import { + agentDMChannelId, + agentDMConversationId, + ensureAgentDM, +} from "./agent-dm"; + +const AGENT: Agent = { + id: "sage", + name: "Sage", + description: "Researcher", + systemPrompt: "Investigate carefully.", + disableToolReferences: [], + selectedMCPs: [], + providerId: "codex-cli", + modelId: "gpt-5", + isBuiltIn: false, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("agent direct messages", () => { + beforeEach(async () => { + await db.open(); + await Promise.all([ + db.messages.clear(), + db.channels.clear(), + db.conversations.clear(), + db.members.clear(), + db.agents.clear(), + ]); + await db.agents.put(AGENT); + }); + + it("creates one private two-person room with a stable conversation", async () => { + const first = await ensureAgentDM(AGENT.id); + const second = await ensureAgentDM(AGENT.id); + + expect(first).toEqual(second); + expect(first).toEqual({ + channelId: agentDMChannelId(AGENT.id), + conversationId: agentDMConversationId(AGENT.id), + }); + expect(await db.channels.count()).toBe(1); + expect(await db.conversations.count()).toBe(1); + expect(await db.channels.get(first.channelId)).toMatchObject({ + name: "Sage", + kind: "dm", + isPrivate: true, + memberIds: [LOCAL_HUMAN_MEMBER_ID, memberIdForAgent(AGENT.id)], + defaultAgentMemberId: memberIdForAgent(AGENT.id), + }); + }); + + it("serializes simultaneous opens without duplicating the room", async () => { + const opened = await Promise.all([ + ensureAgentDM(AGENT.id), + ensureAgentDM(AGENT.id), + ensureAgentDM(AGENT.id), + ]); + + expect(new Set(opened.map((entry) => entry.channelId)).size).toBe(1); + expect(await db.channels.count()).toBe(1); + expect(await db.conversations.count()).toBe(1); + }); + + it("adopts a legacy DM and preserves its transcript", async () => { + const now = new Date(); + await db.conversations.add({ + id: "legacy-conversation", + title: "Old Sage chat", + agentId: AGENT.id, + modelId: null, + activeRevision: 0, + activeProviderId: null, + activeModelId: null, + systemPrompt: null, + metadata: null, + createdAt: now, + updatedAt: now, + }); + await db.channels.add({ + id: "legacy-dm", + workspaceId: "personal", + groupId: null, + name: "Old Sage", + kind: "dm", + isPrivate: true, + memberIds: [LOCAL_HUMAN_MEMBER_ID, memberIdForAgent(AGENT.id)], + conversationId: "legacy-conversation", + defaultAgentMemberId: memberIdForAgent(AGENT.id), + createdAt: now, + updatedAt: now, + }); + await db.messages.add({ + id: "history", + conversationId: "legacy-conversation", + role: "user", + content: "keep this", + createdAt: now, + }); + + const opened = await ensureAgentDM(AGENT.id); + + expect(opened).toEqual({ + channelId: "legacy-dm", + conversationId: "legacy-conversation", + }); + expect(await db.channels.count()).toBe(1); + expect(await db.messages.get("history")).toBeDefined(); + }); + + it("rejects a missing agent without creating records", async () => { + await expect(ensureAgentDM("missing")).rejects.toThrow( + "Agent missing does not exist.", + ); + expect(await db.channels.count()).toBe(0); + expect(await db.conversations.count()).toBe(0); + }); +}); diff --git a/packages/app/src/renderer/libs/agent-dm.ts b/packages/app/src/renderer/libs/agent-dm.ts new file mode 100644 index 00000000..f3c26f98 --- /dev/null +++ b/packages/app/src/renderer/libs/agent-dm.ts @@ -0,0 +1,143 @@ +import { + db, + LOCAL_HUMAN_MEMBER_ID, + LOCAL_WORKSPACE_ID, + memberForAgent, + memberIdForAgent, + type Channel, + type Conversation, +} from "./db"; + +export interface AgentDirectMessage { + channelId: string; + conversationId: string; +} + +function stablePart(value: string): string { + return encodeURIComponent(value); +} + +export function agentDMChannelId(agentId: string): string { + return `dm:agent:${stablePart(agentId)}`; +} + +export function agentDMConversationId(agentId: string): string { + return `conversation:dm:agent:${stablePart(agentId)}`; +} + +function isAgentDM(channel: Channel, agentMemberId: string): boolean { + return ( + channel.kind === "dm" && + channel.memberIds.length === 2 && + channel.memberIds.includes(LOCAL_HUMAN_MEMBER_ID) && + channel.memberIds.includes(agentMemberId) + ); +} + +function conversation( + id: string, + title: string, + agentId: string, + now: Date, +): Conversation { + return { + id, + title, + agentId, + modelId: null, + activeRevision: 0, + activeProviderId: null, + activeModelId: null, + systemPrompt: null, + metadata: null, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Returns the one durable direct-message room for an agent. + * + * The IDs are deterministic so two UI entry points cannot fork the same + * colleague into separate histories. A pre-deterministic DM is adopted when + * one exists, preserving history created by earlier builds. + */ +export async function ensureAgentDM( + agentId: string, +): Promise { + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) throw new Error("An agent id is required."); + + return db.transaction( + "rw", + [db.agents, db.members, db.channels, db.conversations], + async () => { + const agent = await db.agents.get(normalizedAgentId); + if (!agent) { + throw new Error(`Agent ${normalizedAgentId} does not exist.`); + } + + const agentMemberId = memberIdForAgent(agent.id); + if (!(await db.members.get(agentMemberId))) { + await db.members.put(memberForAgent(agent)); + } + + const deterministicChannelId = agentDMChannelId(agent.id); + const deterministic = await db.channels.get(deterministicChannelId); + if (deterministic && !isAgentDM(deterministic, agentMemberId)) { + throw new Error( + `Channel ${deterministicChannelId} is not the direct message for ${agent.name}.`, + ); + } + + const existing = + deterministic ?? + (await db.channels + .filter((channel) => isAgentDM(channel, agentMemberId)) + .first()); + const now = new Date(); + + if (existing) { + if (!(await db.conversations.get(existing.conversationId))) { + await db.conversations.put( + conversation(existing.conversationId, agent.name, agent.id, now), + ); + } + await db.channels.update(existing.id, { + name: agent.name, + isPrivate: true, + memberIds: [LOCAL_HUMAN_MEMBER_ID, agentMemberId], + defaultAgentMemberId: agentMemberId, + updatedAt: now, + }); + return { + channelId: existing.id, + conversationId: existing.conversationId, + }; + } + + const conversationId = agentDMConversationId(agent.id); + if (!(await db.conversations.get(conversationId))) { + await db.conversations.add( + conversation(conversationId, agent.name, agent.id, now), + ); + } + + await db.channels.add({ + id: deterministicChannelId, + workspaceId: LOCAL_WORKSPACE_ID, + groupId: null, + name: agent.name, + kind: "dm", + isPrivate: true, + memberIds: [LOCAL_HUMAN_MEMBER_ID, agentMemberId], + conversationId, + defaultAgentMemberId: agentMemberId, + createdAt: now, + updatedAt: now, + }); + + return { channelId: deterministicChannelId, conversationId }; + }, + ); +} diff --git a/packages/app/src/renderer/libs/agent-host-service.test.ts b/packages/app/src/renderer/libs/agent-host-service.test.ts new file mode 100644 index 00000000..e3b446d3 --- /dev/null +++ b/packages/app/src/renderer/libs/agent-host-service.test.ts @@ -0,0 +1,178 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + AgentHostDispatch, + AgentHostJob, + IAgentHostAPI, +} from "@/shared/types/agent-host"; +import type { ILocalAIAPI } from "@/shared/types/local-ai"; +import type { Member } from "@/shared/types/workspace"; +import { + db, + LOCAL_HUMAN_MEMBER, + type Agent, + type Channel, + type Conversation, +} from "./db/database"; +import { + dispatchAgentHostOffers, + RendererAgentHostService, +} from "./agent-host-service"; + +const agent: Agent = { + id: "fizz", + name: "Fizz", + description: "Builds the app", + systemPrompt: "You are Fizz.", + disableToolReferences: [], + providerId: "codex-cli", + modelId: "gpt-5", + isBuiltIn: false, + createdAt: new Date(), + updatedAt: new Date(), +}; +const member: Member = { + id: "agent:fizz", + workspaceId: "personal", + kind: "agent", + name: "Fizz", + avatar: null, + agentId: agent.id, + status: "idle", +}; +const conversation: Conversation = { + id: "conversation", + title: "Work", + agentId: null, + modelId: null, + activeRevision: 0, + activeProviderId: null, + activeModelId: null, + systemPrompt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), +}; +const channel: Channel = { + id: "channel", + workspaceId: "personal", + groupId: null, + name: "work", + kind: "channel", + isPrivate: false, + memberIds: [LOCAL_HUMAN_MEMBER.id, member.id], + conversationId: conversation.id, + defaultAgentMemberId: member.id, + createdAt: new Date(), + updatedAt: new Date(), +}; +const job: AgentHostJob = { + id: "job", + channelId: channel.id, + conversationId: conversation.id, + triggerMessageId: "human-message", + contextMessageIds: ["older-message", "human-message"], + mode: "open-floor", + offeredAgentMemberIds: [member.id], + agentId: agent.id, + agentMemberId: member.id, + chain: { hops: 0, invoked: [member.id] }, + status: "running", + attempts: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +describe("RendererAgentHostService", () => { + const enqueue = vi.fn(async () => ({ success: true, jobs: [job] })); + + beforeEach(async () => { + enqueue.mockClear(); + await db.open(); + await Promise.all([ + db.messages.clear(), + db.pendingTurns.clear(), + db.conversations.clear(), + db.channels.clear(), + db.members.clear(), + db.agents.clear(), + ]); + await db.agents.put(agent); + await db.members.bulkPut([LOCAL_HUMAN_MEMBER, member]); + await db.conversations.put(conversation); + await db.channels.put(channel); + await db.messages.bulkPut([ + { + id: "older-message", + conversationId: conversation.id, + role: "assistant", + content: "Earlier", + senderId: member.id, + createdAt: new Date(1), + }, + { + id: "human-message", + conversationId: conversation.id, + role: "user", + content: "Take this", + senderId: LOCAL_HUMAN_MEMBER.id, + createdAt: new Date(2), + }, + { + id: "later-message", + conversationId: conversation.id, + role: "assistant", + content: "Must not leak into the frozen offer", + senderId: member.id, + createdAt: new Date(3), + }, + ]); + Object.assign(globalThis, { + window: { + agentHost: { enqueue } as unknown as IAgentHostAPI, + localAI: {} as ILocalAIAPI, + }, + }); + }); + + it("prepares a concurrent tool-only turn from the frozen message boundary", async () => { + const service = new RendererAgentHostService(); + const before = await db.messages.count(); + const prepared = await service.prepareTurn(job); + + expect(prepared.request).toMatchObject({ + conversationId: conversation.id, + providerId: "codex-cli", + modelId: "gpt-5", + concurrent: true, + operation: { + kind: "bootstrap", + messages: [ + { role: "assistant", content: "Earlier" }, + { role: "user", content: "You: Take this" }, + ], + }, + agent: { id: agent.id, memberId: member.id }, + }); + expect(prepared.request.agent?.systemPrompt).toContain( + "The ONLY way to say something here is to call the send_message tool", + ); + expect(await db.messages.count()).toBe(before); + expect(await db.pendingTurns.count()).toBe(0); + }); + + it("only enqueues a collaboration-layer dispatch", async () => { + const dispatch: AgentHostDispatch = { + channelId: channel.id, + conversationId: conversation.id, + triggerMessageId: "human-message", + contextMessageIds: ["human-message"], + mode: "direct", + offeredAgentMemberIds: [member.id], + targets: [{ agentId: agent.id, memberId: member.id }], + chain: { hops: 0, invoked: [member.id] }, + }; + expect(await dispatchAgentHostOffers(dispatch)).toEqual([job]); + expect(enqueue).toHaveBeenCalledWith(dispatch); + }); +}); diff --git a/packages/app/src/renderer/libs/agent-host-service.ts b/packages/app/src/renderer/libs/agent-host-service.ts new file mode 100644 index 00000000..6a21a2f1 --- /dev/null +++ b/packages/app/src/renderer/libs/agent-host-service.ts @@ -0,0 +1,344 @@ +import type { + AgentHostDispatch, + AgentHostJob, + AgentHostRendererRequest, + PreparedAgentHostTurn, +} from "@/shared/types/agent-host"; +import type { + LocalAIInteractionResponse, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import { WORKSPACE_SEND_MESSAGE_TOOL } from "@/shared/types/workspace-perception"; +import type { Member } from "@/shared/types/workspace"; +import { db, type Agent, type Channel, type Message } from "./db/database"; +import { useSelectionStore } from "./db/ui-state"; +import { + buildChannelContext, + projectFor, + type OfferedPeer, +} from "./agent-projection"; +import { + resolveConversationProviderSelection, + resolveNativeProviderSelection, +} from "./provider-selection"; +import { resolveLocalAIProviderId } from "./stores/model-config-store"; +import { DEFAULT_LOCAL_AI_MODEL_ID } from "./local-ai"; +import { handleWorkspaceQueryInteraction } from "./workspace-perception"; +import { useTypingStore } from "./stores/typing-store"; +import { useUserInputStore } from "./stores/user-input-store"; + +interface ActiveOffer { + job: AgentHostJob; + requestId: string; +} + +/** + * Enqueues an offer that the collaboration layer has already routed and + * persisted. This helper deliberately does not write messages or pick agents. + */ +export async function dispatchAgentHostOffers( + dispatch: AgentHostDispatch, +): Promise { + const api = window.agentHost; + if (!api) { + throw new Error( + "Background Agent Host is unavailable. Restart the Convera desktop app.", + ); + } + const result = await api.enqueue(dispatch); + if (!result.success) { + throw new Error(result.error || "Agent Host rejected the agent offer."); + } + return result.jobs ?? []; +} + +function toProjectable(messages: Message[]) { + return messages.map((message) => ({ + id: message.id, + senderId: message.senderId, + replyToMessageId: message.replyToMessageId, + role: message.role, + content: message.content, + })); +} + +async function loadOffer(job: AgentHostJob): Promise<{ + channel: Channel; + members: Member[]; + self: Member; + agent: Agent; + messages: Message[]; +}> { + const channel = await db.channels.get(job.channelId); + if (!channel || channel.conversationId !== job.conversationId) { + throw new Error("The Agent Host job does not belong to this channel."); + } + if (!channel.memberIds.includes(job.agentMemberId)) { + throw new Error("The agent is no longer a member of this channel."); + } + + const [memberRows, agent, messageRows] = await Promise.all([ + db.members.bulkGet(channel.memberIds), + db.agents.get(job.agentId), + db.messages.bulkGet(job.contextMessageIds), + ]); + const members = memberRows.filter( + (member): member is Member => member !== undefined, + ); + const self = members.find((member) => member.id === job.agentMemberId); + if ( + !self || + self.kind !== "agent" || + self.agentId !== job.agentId || + !agent + ) { + throw new Error( + "The Agent Host identity no longer names a runnable agent.", + ); + } + if (messageRows.some((message) => message === undefined)) { + throw new Error( + "Part of this agent offer's frozen context no longer exists.", + ); + } + const messages = messageRows as Message[]; + if ( + !messages.some((message) => message.id === job.triggerMessageId) || + messages.some((message) => message.conversationId !== job.conversationId) + ) { + throw new Error( + "The frozen Agent Host context is not valid for this channel.", + ); + } + return { channel, members, self, agent, messages }; +} + +async function offeredPeers(job: AgentHostJob): Promise { + const memberRows = await db.members.bulkGet(job.offeredAgentMemberIds); + const members = memberRows.filter( + (member): member is Member => member?.kind === "agent", + ); + const agents = await db.agents.bulkGet( + members.flatMap((member) => (member.agentId ? [member.agentId] : [])), + ); + const descriptions = new Map( + agents + .filter((agent): agent is Agent => agent !== undefined) + .map((agent) => [agent.id, agent.description]), + ); + return members.map((member) => ({ + id: member.id, + name: member.name, + description: member.agentId + ? descriptions.get(member.agentId) || undefined + : undefined, + })); +} + +/** + * Renderer adapter for durable jobs. Collaboration semantics stay in the + * renderer/Dexie layer; Electron main owns only job lifecycle and execution. + */ +export class RendererAgentHostService { + private readonly active = new Map(); + private disposeRequest?: () => void; + private disposeEvent?: () => void; + + start(): void { + const api = window.agentHost; + if (!api || this.disposeRequest || this.disposeEvent) return; + this.disposeRequest = api.onRequest((request) => { + void this.handleRequest(request); + }); + this.disposeEvent = api.onEvent((event) => { + if (event.type === "stream") { + void this.handleStream(event.jobId, event.event); + return; + } + if ( + event.job.status === "completed" || + event.job.status === "failed" || + event.job.status === "cancelled" || + event.job.status === "interrupted" + ) { + this.clearOffer(event.job.id); + } + }); + void api.ready(); + } + + dispose(): void { + this.disposeRequest?.(); + this.disposeEvent?.(); + this.disposeRequest = undefined; + this.disposeEvent = undefined; + for (const offer of this.active.values()) { + useTypingStore.getState().stopTyping(offer.requestId); + useUserInputStore.getState().dismissRequest(offer.requestId); + } + this.active.clear(); + } + + private async handleRequest( + request: AgentHostRendererRequest, + ): Promise { + try { + const data = await this.prepareTurn(request.job); + await window.agentHost?.respond({ + requestId: request.id, + success: true, + data, + }); + } catch (error) { + await window.agentHost?.respond({ + requestId: request.id, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private clearOffer(jobId: string): void { + const active = this.active.get(jobId); + if (!active) return; + useTypingStore.getState().stopTyping(active.requestId); + useUserInputStore.getState().dismissRequest(active.requestId); + this.active.delete(jobId); + } + + async prepareTurn(job: AgentHostJob): Promise { + const { channel, members, self, agent, messages } = await loadOffer(job); + const conversation = await db.conversations.get(job.conversationId); + if (!conversation) { + throw new Error("The channel conversation no longer exists."); + } + const selectionState = useSelectionStore.getState(); + const defaults = resolveNativeProviderSelection( + selectionState.defaultConfigId, + selectionState.defaultModelId, + ); + const conversationSelection = resolveConversationProviderSelection( + conversation, + defaults, + ); + const providerId = resolveLocalAIProviderId( + agent.providerId ?? conversationSelection.configId, + ); + const selectedModelId = agent.modelId ?? conversationSelection.modelId; + const requestId = crypto.randomUUID(); + const mayPass = job.mode === "open-floor"; + const peers = mayPass ? await offeredPeers(job) : []; + const roomContext = buildChannelContext( + self, + channel.name, + members, + mayPass, + peers, + channel.id, + ); + const systemPrompt = agent.systemPrompt + ? `${agent.systemPrompt}\n\n${roomContext}` + : roomContext; + const prepared: PreparedAgentHostTurn = { + request: { + requestId, + conversationId: job.conversationId, + turnId: crypto.randomUUID(), + providerId, + modelId: + selectedModelId === DEFAULT_LOCAL_AI_MODEL_ID + ? undefined + : selectedModelId, + concurrent: true, + operation: { + kind: "bootstrap", + messages: projectFor( + job.agentMemberId, + toProjectable(messages), + members, + ), + }, + agent: { + id: job.agentId, + memberId: job.agentMemberId, + systemPrompt, + }, + }, + }; + this.active.set(job.id, { job, requestId }); + return prepared; + } + + private async respondToInteraction( + event: Extract, + response: LocalAIInteractionResponse, + ): Promise { + const result = await window.localAI.respondToInteraction( + event.requestId, + event.interactionId, + response, + ); + if (!result.success || !result.data?.accepted) { + throw new Error( + result.error?.message || + "The background agent interaction is no longer active.", + ); + } + } + + private async handleStream( + jobId: string, + event: LocalAIStreamEvent, + ): Promise { + const active = this.active.get(jobId); + if (!active || event.requestId !== active.requestId) return; + + if (event.type === "ui-message") { + const chunk = event.chunk as { type?: string; toolName?: string }; + if ( + chunk.type === "tool-input-start" && + chunk.toolName?.endsWith(WORKSPACE_SEND_MESSAGE_TOOL) + ) { + useTypingStore + .getState() + .startTyping( + event.requestId, + active.job.agentMemberId, + active.job.conversationId, + ); + } + return; + } + + if (event.type === "interaction") { + const respond = (response: LocalAIInteractionResponse) => + this.respondToInteraction(event, response); + const workspaceEvent = + event.name === "workspace:query" && + typeof event.input === "object" && + event.input !== null && + "kind" in event.input && + event.input.kind === "send_message" + ? { + ...event, + input: { + ...event.input, + agentHost: { + jobId: active.job.id, + triggerMessageId: active.job.triggerMessageId, + contextMessageIds: active.job.contextMessageIds, + chain: active.job.chain, + }, + }, + } + : event; + if (handleWorkspaceQueryInteraction(workspaceEvent, respond)) return; + useUserInputStore.getState().registerInteraction(event, respond); + return; + } + + if (event.type === "finish" || event.type === "error") { + this.clearOffer(jobId); + } + } +} diff --git a/packages/app/src/renderer/libs/agent-projection.test.ts b/packages/app/src/renderer/libs/agent-projection.test.ts index e58d6902..17743b74 100644 --- a/packages/app/src/renderer/libs/agent-projection.test.ts +++ b/packages/app/src/renderer/libs/agent-projection.test.ts @@ -115,6 +115,70 @@ describe("projectFor", () => { ]); }); + it("makes a direct reply and its quoted parent explicit to the agent", () => { + const replied: ProjectableMessage[] = [ + ...transcript, + { + id: "4", + senderId: maya.id, + role: "user", + content: "Can you expand that?", + replyToMessageId: "2", + }, + ]; + + const projected = projectFor(honey.id, replied, members); + expect(projected.at(-1)).toEqual({ + role: "user", + content: + 'Maya Chen: [Replying to Fizz (2): "three-beat plan"]\nCan you expand that?', + }); + }); + + it("keeps reply context when the quoted parent is dropped by the budget", () => { + const replied: ProjectableMessage[] = [ + { + id: "parent", + senderId: fizz.id, + role: "assistant", + content: `important premise ${"x".repeat(1_000)}`, + }, + { + id: "reply", + senderId: maya.id, + role: "user", + content: "Please act on this.", + replyToMessageId: "parent", + }, + ]; + + const projected = projectFor(honey.id, replied, members, { maxChars: 350 }); + expect(projected).toHaveLength(1); + expect(projected[0].content).toContain("Replying to Fizz (parent)"); + expect(projected[0].content).toContain("important premise"); + expect(projected[0].content).toContain("Please act on this."); + }); + + it("marks a dangling reply without inventing its author or contents", () => { + const projected = projectFor( + fizz.id, + [ + { + id: "reply", + senderId: maya.id, + role: "user", + content: "Still relevant?", + replyToMessageId: "gone", + }, + ], + members, + ); + + expect(projected[0].content).toBe( + "Maya Chen: [Replying to unavailable message (gone)]\nStill relevant?", + ); + }); + it("truncates oldest first but keeps system messages", () => { const long: ProjectableMessage[] = [ { id: "s", role: "system", content: "SYSTEM" }, diff --git a/packages/app/src/renderer/libs/agent-projection.ts b/packages/app/src/renderer/libs/agent-projection.ts index 3fbad1ff..932eeedf 100644 --- a/packages/app/src/renderer/libs/agent-projection.ts +++ b/packages/app/src/renderer/libs/agent-projection.ts @@ -19,6 +19,7 @@ export interface ProjectableMessage { senderId?: string; role: "user" | "assistant" | "system" | "tool"; content: string; + replyToMessageId?: string; } export interface ProjectOptions { @@ -27,6 +28,7 @@ export interface ProjectOptions { } const DEFAULT_MAX_CHARS = 400_000; +const REPLY_EXCERPT_MAX_CHARS = 240; function speakerName( message: ProjectableMessage, @@ -39,6 +41,35 @@ function speakerName( return members.get(message.senderId)?.name; } +function replyContext( + message: ProjectableMessage, + messagesById: Map, + members: Map, +): string | undefined { + if (!message.replyToMessageId) return undefined; + const parent = messagesById.get(message.replyToMessageId); + if (!parent) { + return `[Replying to unavailable message (${message.replyToMessageId})]`; + } + + const author = speakerName(parent, members) ?? "Assistant"; + const normalized = parent.content.replace(/\s+/g, " ").trim(); + const excerpt = + normalized.length > REPLY_EXCERPT_MAX_CHARS + ? `${normalized.slice(0, REPLY_EXCERPT_MAX_CHARS)}…` + : normalized || "(no text)"; + return `[Replying to ${author} (${message.replyToMessageId}): ${JSON.stringify(excerpt)}]`; +} + +function projectedContent( + message: ProjectableMessage, + messagesById: Map, + members: Map, +): string { + const context = replyContext(message, messagesById, members); + return context ? `${context}\n${message.content}` : message.content; +} + export function projectFor( targetMemberId: string, messages: ProjectableMessage[], @@ -46,6 +77,10 @@ export function projectFor( options: ProjectOptions = {}, ): LocalAIMessage[] { const byId = new Map(members.map((member) => [member.id, member])); + const messagesById = new Map(); + for (const message of messages) { + if (message.id) messagesById.set(message.id, message); + } const maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; const projected: LocalAIMessage[] = []; @@ -62,16 +97,17 @@ export function projectFor( const isSelf = message.senderId ? message.senderId === targetMemberId : message.role === "assistant"; + const content = projectedContent(message, messagesById, byId); if (isSelf) { - projected.push({ role: "assistant", content: message.content }); + projected.push({ role: "assistant", content }); continue; } const name = speakerName(message, byId); projected.push({ role: "user", - content: name ? `${name}: ${message.content}` : message.content, + content: name ? `${name}: ${content}` : content, }); } diff --git a/packages/app/src/renderer/libs/agent-routing.test.ts b/packages/app/src/renderer/libs/agent-routing.test.ts index 8abd2260..ef5a91b4 100644 --- a/packages/app/src/renderer/libs/agent-routing.test.ts +++ b/packages/app/src/renderer/libs/agent-routing.test.ts @@ -128,6 +128,36 @@ describe("routeMessage", () => { expect(open.invoke).toEqual([fizz.id, honey.id, bean.id]); }); + it("addresses the agent whose message a human replied to", () => { + const reply = routeMessage({ + message: { senderId: maya.id, content: "Could you expand that?" }, + members, + replyToSenderId: fizz.id, + openFloor: true, + }); + expect(reply.invoke).toEqual([fizz.id]); + }); + + it("lets an explicit mention override the replied-to agent", () => { + const reply = routeMessage({ + message: { senderId: maya.id, content: "@Honey could you take this?" }, + members, + replyToSenderId: fizz.id, + openFloor: true, + }); + expect(reply.invoke).toEqual([honey.id]); + }); + + it("keeps the floor open when the replied-to sender is not an agent", () => { + const reply = routeMessage({ + message: { senderId: maya.id, content: "Following up" }, + members, + replyToSenderId: maya.id, + openFloor: true, + }); + expect(reply.invoke).toEqual([fizz.id, honey.id, bean.id]); + }); + it("keeps a named mention exclusive even with the floor open", () => { const addressed = routeMessage({ message: { senderId: maya.id, content: "@Honey can you look at this" }, diff --git a/packages/app/src/renderer/libs/agent-routing.ts b/packages/app/src/renderer/libs/agent-routing.ts index fbcffe2c..f3155f9d 100644 --- a/packages/app/src/renderer/libs/agent-routing.ts +++ b/packages/app/src/renderer/libs/agent-routing.ts @@ -42,6 +42,12 @@ export interface RoutableMessage { export interface RouteInput { message: RoutableMessage; members: Member[]; + /** + * Sender of the message this one directly replies to. Explicit mentions win; + * otherwise replying to an agent addresses that agent instead of opening the + * floor. Resolve this from the authoritative transcript before routing. + */ + replyToSenderId?: string | null; /** * Answers an unaddressed message. Set for 1:1 chats; leave null in channels * so `openFloor` decides instead. @@ -70,6 +76,7 @@ export const NEW_CHAIN: ChainState = { hops: 0, invoked: [] }; export function routeMessage({ message, members, + replyToSenderId, defaultAgentMemberId, openFloor, chain, @@ -88,13 +95,19 @@ export function routeMessage({ } const mentioned = parseMentions(message.content, members); + const repliedToAgent = + replyToSenderId && byId.get(replyToSenderId)?.kind === "agent" + ? replyToSenderId + : undefined; const candidates = mentioned.length ? mentioned - : defaultAgentMemberId - ? [defaultAgentMemberId] - : openFloor - ? members.filter((member) => member.kind === "agent").map((m) => m.id) - : []; + : repliedToAgent + ? [repliedToAgent] + : defaultAgentMemberId + ? [defaultAgentMemberId] + : openFloor + ? members.filter((member) => member.kind === "agent").map((m) => m.id) + : []; const invoke: string[] = []; let limitReached = false; diff --git a/packages/app/src/renderer/libs/agent-speech.ts b/packages/app/src/renderer/libs/agent-speech.ts index 39b81175..9044cbd3 100644 --- a/packages/app/src/renderer/libs/agent-speech.ts +++ b/packages/app/src/renderer/libs/agent-speech.ts @@ -2,6 +2,9 @@ import { addMessage, db, type Channel } from "./db"; import { registerWorkspaceSendMessage } from "./workspace-perception"; import type { WorkspaceQueryResult } from "@/shared/types/workspace-perception"; import { parseMentions } from "./mention-parser"; +import { routeMessage } from "./agent-routing"; +import { dispatchAgentHostOffers } from "./agent-host-service"; +import type { Member } from "@/shared/types/workspace"; /** * Speaking, as an agent does it. @@ -20,6 +23,7 @@ async function speak( channel: Channel, senderId: string, content: string, + replyToMessageId?: string, ): Promise { const members = await db.members.toArray(); return addMessage(channel.conversationId, { @@ -29,6 +33,7 @@ async function speak( // Parsed here rather than trusted from the model: a mention is what routes // the next turn, so it has to reflect the text that was actually posted. mentions: parseMentions(content, members), + ...(replyToMessageId ? { replyToMessageId } : {}), status: "completed", }); } @@ -44,6 +49,8 @@ export function installAgentSpeech(): void { viewerMemberId, channelId, content, + replyToMessageId, + agentHost, }): Promise => { const channel = await db.channels.get(channelId); if (!channel) { @@ -55,10 +62,55 @@ export function installAgentSpeech(): void { }, }; } + const messageId = await speak( + channel, + viewerMemberId, + content, + replyToMessageId, + ); + if (agentHost) { + const memberRows = await db.members.bulkGet(channel.memberIds); + const members = memberRows.filter( + (member): member is Member => member !== undefined, + ); + const routed = routeMessage({ + message: { senderId: viewerMemberId, content }, + members, + replyToSenderId: replyToMessageId + ? (await db.messages.get(replyToMessageId))?.senderId + : undefined, + defaultAgentMemberId: null, + openFloor: false, + chain: agentHost.chain, + }); + if (routed.invoke.length > 0) { + const targets = routed.invoke.flatMap((memberId) => { + const member = members.find( + (candidate) => candidate.id === memberId, + ); + return member?.agentId + ? [{ agentId: member.agentId, memberId }] + : []; + }); + await dispatchAgentHostOffers({ + channelId: channel.id, + conversationId: channel.conversationId, + triggerMessageId: messageId, + contextMessageIds: [ + ...agentHost.contextMessageIds.slice(-499), + messageId, + ], + mode: "direct", + offeredAgentMemberIds: routed.invoke, + targets, + chain: routed.chain, + }); + } + } return { ok: true, kind: "send_message", - messageId: await speak(channel, viewerMemberId, content), + messageId, }; }, ); diff --git a/packages/app/src/renderer/libs/conversation-branch-lifecycle.test.ts b/packages/app/src/renderer/libs/conversation-branch-lifecycle.test.ts index ac06ef89..f08a1ed4 100644 --- a/packages/app/src/renderer/libs/conversation-branch-lifecycle.test.ts +++ b/packages/app/src/renderer/libs/conversation-branch-lifecycle.test.ts @@ -6,7 +6,7 @@ import { replayPendingConversationDeletion, } from "./conversation-lifecycle"; import { db, type Conversation } from "./db/database"; -import { databaseInitialization } from "./db/hooks"; +import { branchFromMessage, databaseInitialization } from "./db/hooks"; const sourceConversationId = "branch-source"; const createdAt = new Date("2026-07-31T00:00:00.000Z"); @@ -78,6 +78,33 @@ afterAll(async () => { }); describe("conversation branch lifecycle", () => { + it("remaps reply references to the copied message ids", async () => { + await seedSource(); + await db.messages.add({ + id: "source-reply", + conversationId: sourceConversationId, + role: "assistant", + content: "copied reply", + senderId: "agent:fizz", + replyToMessageId: "source-user", + status: "completed", + createdAt: new Date(createdAt.getTime() + 1), + }); + + const branchId = await branchFromMessage(sourceConversationId, 1); + const copied = await db.messages + .where("conversationId") + .equals(branchId) + .sortBy("createdAt"); + + expect(copied).toHaveLength(2); + expect(copied[0].id).not.toBe("source-user"); + expect(copied[1]).toMatchObject({ + content: "copied reply", + replyToMessageId: copied[0].id, + }); + }); + it("persists cleanup intent before main and atomically publishes the local branch", async () => { await seedSource(); let targetConversationId: string | undefined; diff --git a/packages/app/src/renderer/libs/conversation-send-context.test.ts b/packages/app/src/renderer/libs/conversation-send-context.test.ts index 3568bc78..2b2145e0 100644 --- a/packages/app/src/renderer/libs/conversation-send-context.test.ts +++ b/packages/app/src/renderer/libs/conversation-send-context.test.ts @@ -51,12 +51,16 @@ describe("authoritative conversation send context", () => { }; it("uses the target Dexie provider and transcript instead of renderer state", async () => { + const target = { + ...message("target-user", "target history"), + senderId: "me", + mentions: ["agent:fizz"], + reactions: { "👍": ["me"] }, + replyToMessageId: "earlier-message", + }; const readSnapshot = vi.fn(async () => ({ conversation: conversation(), - messages: [ - message("target-user", "target history"), - message("target-tool", "hidden tool result", "tool"), - ], + messages: [target, message("target-tool", "hidden tool result", "tool")], })); await expect( @@ -72,7 +76,16 @@ describe("authoritative conversation send context", () => { configId: "claude-code", modelId: "claude-sonnet", }, - messages: [{ id: "target-user", content: "target history" }], + messages: [ + { + id: "target-user", + content: "target history", + senderId: "me", + mentions: ["agent:fizz"], + reactions: { "👍": ["me"] }, + replyToMessageId: "earlier-message", + }, + ], }); expect(readSnapshot).toHaveBeenCalledWith("conversation-b"); }); diff --git a/packages/app/src/renderer/libs/conversation-send-context.ts b/packages/app/src/renderer/libs/conversation-send-context.ts index fb2b6816..63d4610e 100644 --- a/packages/app/src/renderer/libs/conversation-send-context.ts +++ b/packages/app/src/renderer/libs/conversation-send-context.ts @@ -78,6 +78,10 @@ function toRendererMessages(messages: Message[]): RendererMessage[] { id: message.id, role: message.role, content: message.content, + senderId: message.senderId, + mentions: message.mentions, + reactions: message.reactions, + replyToMessageId: message.replyToMessageId, parts: message.parts as RendererMessage["parts"], experimental_attachments: message.experimental_attachments as RendererMessage["experimental_attachments"], diff --git a/packages/app/src/renderer/libs/db/database.ts b/packages/app/src/renderer/libs/db/database.ts index 709bb1a9..9031d905 100644 --- a/packages/app/src/renderer/libs/db/database.ts +++ b/packages/app/src/renderer/libs/db/database.ts @@ -72,6 +72,8 @@ export interface Message { mentions?: string[]; /** emoji -> Member.id[] who reacted. Optional field, no index needed. */ reactions?: Record; + /** Message.id this row directly replies to. Always belongs to this conversation. */ + replyToMessageId?: string; parts?: unknown[]; experimental_attachments?: Array<{ url: string; diff --git a/packages/app/src/renderer/libs/db/hooks.ts b/packages/app/src/renderer/libs/db/hooks.ts index 7b1fc6d4..62d34574 100644 --- a/packages/app/src/renderer/libs/db/hooks.ts +++ b/packages/app/src/renderer/libs/db/hooks.ts @@ -893,15 +893,23 @@ export async function branchFromMessage( updatedAt: now, }); const baseTime = Date.now(); + const copiedMessageIds = new Map( + messagesToCopy.map((message) => [message.id, crypto.randomUUID()]), + ); await db.messages.bulkAdd( messagesToCopy.map((msg, index) => ({ - id: crypto.randomUUID(), + id: copiedMessageIds.get(msg.id)!, conversationId: newConvId, role: msg.role, content: msg.content, senderId: msg.senderId, mentions: msg.mentions, reactions: msg.reactions, + ...(msg.replyToMessageId && copiedMessageIds.has(msg.replyToMessageId) + ? { + replyToMessageId: copiedMessageIds.get(msg.replyToMessageId), + } + : {}), turnId: msg.turnId, revision: msg.revision, providerId: msg.providerId, diff --git a/packages/app/src/renderer/libs/hooks/use-agent-host-jobs.ts b/packages/app/src/renderer/libs/hooks/use-agent-host-jobs.ts new file mode 100644 index 00000000..51bee8db --- /dev/null +++ b/packages/app/src/renderer/libs/hooks/use-agent-host-jobs.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { AgentHostJob } from "@/shared/types/agent-host"; + +export function useAgentHostJobs(channelId?: string) { + const [jobs, setJobs] = useState([]); + const [error, setError] = useState(); + + useEffect(() => { + const api = window.agentHost; + if (!api) { + setError("Background Agent Host is unavailable."); + return; + } + let mounted = true; + void api.listJobs().then((result) => { + if (!mounted) return; + if (result.success) setJobs(result.jobs ?? []); + else setError(result.error || "Could not read background agent jobs."); + }); + const dispose = api.onEvent((event) => { + if (event.type !== "job") return; + setJobs((current) => { + const index = current.findIndex((job) => job.id === event.job.id); + if (index === -1) return [...current, event.job]; + const next = [...current]; + next[index] = event.job; + return next; + }); + }); + return () => { + mounted = false; + dispose(); + }; + }, []); + + const visibleJobs = useMemo( + () => + channelId ? jobs.filter((job) => job.channelId === channelId) : jobs, + [channelId, jobs], + ); + const activeJobs = useMemo( + () => + visibleJobs.filter( + (job) => job.status === "queued" || job.status === "running", + ), + [visibleJobs], + ); + const cancel = useCallback(async (jobId: string) => { + const result = await window.agentHost?.cancel(jobId); + if (!result?.success) { + setError(result?.error || "Could not stop background agent work."); + } + return result?.cancelled === true; + }, []); + + return { jobs: visibleJobs, activeJobs, error, cancel }; +} diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts index d657d843..12ca2d59 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -111,6 +111,7 @@ function toMessageSnapshots(messages: Message[]): MessageSnapshot[] { senderId: message.senderId, mentions: message.mentions, reactions: message.reactions, + replyToMessageId: message.replyToMessageId, })); } @@ -161,12 +162,14 @@ export function useLocalAIChat(): UseLocalAIChatResult { // Who the active request speaks as, so a tool-call signal can be attributed. const activeResponderIdRef = useRef(undefined); + const activeConversationIdRef = useRef(undefined); /** Any exit from a request retires its indicator, including a failed one. */ const clearTyping = useCallback(() => { const requestId = activeRequestIdRef.current; if (requestId) useTypingStore.getState().stopTyping(requestId); activeResponderIdRef.current = undefined; + activeConversationIdRef.current = undefined; }, []); const handleEvent = useCallback( @@ -182,11 +185,16 @@ export function useLocalAIChat(): UseLocalAIChatResult { if ( chunk.type === "tool-input-start" && chunk.toolName?.endsWith(WORKSPACE_SEND_MESSAGE_TOOL) && - activeResponderIdRef.current + activeResponderIdRef.current && + activeConversationIdRef.current ) { useTypingStore .getState() - .startTyping(event.requestId, activeResponderIdRef.current); + .startTyping( + event.requestId, + activeResponderIdRef.current, + activeConversationIdRef.current, + ); } activeUIMessageStreamRef.current?.push(event.chunk); return; @@ -307,7 +315,9 @@ export function useLocalAIChat(): UseLocalAIChatResult { : message; setMessages((current) => current.map((candidate) => - candidate.id === assistantMessageId ? stamped : candidate, + candidate.id === assistantMessageId + ? { ...candidate, ...stamped } + : candidate, ), ); }, @@ -342,6 +352,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { ); activeRequestIdRef.current = requestId; activeResponderIdRef.current = options.responderId; + activeConversationIdRef.current = options.conversationId; activeTurnRef.current = activeTurn; activeUIMessageStreamRef.current = uiMessageStream; unsubscribeRef.current = localAI.onEvent(requestId, (event) => { diff --git a/packages/app/src/renderer/libs/open-floor-dispatch.ts b/packages/app/src/renderer/libs/open-floor-dispatch.ts deleted file mode 100644 index 30c34bcc..00000000 --- a/packages/app/src/renderer/libs/open-floor-dispatch.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { LocalAIMessage } from "@/shared/types/local-ai"; -import type { LocalAIProviderId } from "./local-ai"; -import { getLocalAI } from "./local-ai"; -import { useTypingStore } from "./stores/typing-store"; -import { WORKSPACE_SEND_MESSAGE_TOOL } from "@/shared/types/workspace-perception"; - -/** - * Runs an open floor: every colleague offered the message thinks at the same - * time, and each one speaks — or does not — through `send_message`. - * - * These turns deliberately bypass the single-request chat hook. That hook exists - * to own one reply row: it stages a shell, streams into it, and reconciles it. - * None of that applies here, because a colleague who was merely offered the - * floor reserves nothing and may say nothing at all. Sharing the hook would - * have meant queueing them one behind another, which is exactly the "they take - * turns" behaviour this replaces — a room where three people were asked does - * not make them answer in series. - * - * Nothing is written from here. `send_message` posts through the workspace tool - * seam, so the transcript is only ever touched by an agent that chose to speak. - */ -export interface OpenFloorTurn { - memberId: string; - agentId: string; - systemPrompt: string; - providerId: LocalAIProviderId; - modelId?: string; - requestMessages: LocalAIMessage[]; - conversationId: string; - cwd?: string; -} - -export interface OpenFloorResult { - memberId: string; - spoke: boolean; - error?: string; -} - -function runOne(turn: OpenFloorTurn): Promise { - const localAI = getLocalAI(); - if (!localAI) { - return Promise.resolve({ - memberId: turn.memberId, - spoke: false, - error: "Local AI runtime is not available.", - }); - } - - const requestId = crypto.randomUUID(); - const typing = useTypingStore.getState(); - - return new Promise((resolve) => { - let spoke = false; - let settled = false; - const finish = (result: OpenFloorResult) => { - if (settled) return; - settled = true; - typing.stopTyping(requestId); - unsubscribe(); - resolve(result); - }; - - const unsubscribe = localAI.onEvent(requestId, (event) => { - if (event.type === "ui-message") { - const chunk = event.chunk as { type?: string; toolName?: string }; - // Reaching for the speech tool is the only honest moment to say - // someone is typing: it is the agent committing to speak. - if ( - chunk.type === "tool-input-start" && - chunk.toolName?.endsWith(WORKSPACE_SEND_MESSAGE_TOOL) - ) { - spoke = true; - typing.startTyping(requestId, turn.memberId); - } - return; - } - if (event.type === "error") { - finish({ - memberId: turn.memberId, - spoke, - error: event.error.message, - }); - return; - } - if (event.type === "finish") { - finish({ memberId: turn.memberId, spoke }); - } - }); - - void localAI - .startChat({ - requestId, - conversationId: turn.conversationId, - turnId: crypto.randomUUID(), - providerId: turn.providerId, - modelId: turn.modelId, - concurrent: true, - operation: { kind: "bootstrap", messages: turn.requestMessages }, - agent: { - id: turn.agentId, - memberId: turn.memberId, - systemPrompt: turn.systemPrompt, - }, - ...(turn.cwd ? { options: { cwd: turn.cwd } } : {}), - }) - .then((result) => { - if (!result.success || !result.accepted) { - finish({ - memberId: turn.memberId, - spoke: false, - error: result.error?.message ?? "The runtime rejected the turn.", - }); - } - }) - .catch((error: unknown) => - finish({ - memberId: turn.memberId, - spoke: false, - error: error instanceof Error ? error.message : String(error), - }), - ); - }); -} - -/** One agent failing is that agent staying quiet, not the room going down. */ -export function dispatchOpenFloor( - turns: OpenFloorTurn[], -): Promise { - return Promise.all(turns.map(runOne)); -} diff --git a/packages/app/src/renderer/libs/stores/chat-history-store.ts b/packages/app/src/renderer/libs/stores/chat-history-store.ts index 6197398c..1c519706 100644 --- a/packages/app/src/renderer/libs/stores/chat-history-store.ts +++ b/packages/app/src/renderer/libs/stores/chat-history-store.ts @@ -225,6 +225,7 @@ export function useChatHistoryStore() { // updateMessages rewrites every row, so reactions must ride along or // a save would silently drop them. reactions: m.reactions, + replyToMessageId: m.replyToMessageId, parts: m.parts, experimental_attachments: m.experimental_attachments?.map((a) => ({ url: a.url, @@ -248,6 +249,7 @@ export function useChatHistoryStore() { senderId: message.senderId, mentions: message.mentions, reactions: message.reactions, + replyToMessageId: message.replyToMessageId, parts: message.parts, experimental_attachments: message.experimental_attachments?.map( (a) => ({ @@ -287,6 +289,7 @@ export function useChatHistory( senderId: m.senderId, mentions: m.mentions, reactions: m.reactions, + replyToMessageId: m.replyToMessageId, parts: m.parts as Message["parts"], experimental_attachments: m.experimental_attachments as Message["experimental_attachments"], diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index f836c75c..962e0034 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -1,5 +1,4 @@ import { ServerInfo, ToolDefinition } from "@/shared/types/mcp"; -import type { LocalAIMessage } from "@/shared/types/local-ai"; import { AppSettings } from "@/shared/types/settings"; import type { Attachment, Message, UIMessage } from "@/renderer/types/chat"; import React, { @@ -29,11 +28,9 @@ import { buildChannelContext, isPass, projectFor, - projectOpenFloor, type OfferedPeer, } from "../agent-projection"; import { routeMessage, type ChainState } from "../agent-routing"; -import { dispatchOpenFloor } from "../open-floor-dispatch"; import { parseMentions } from "../mention-parser"; import { LOCAL_HUMAN_MEMBER_ID, @@ -60,6 +57,7 @@ import { reconcilePendingTurns, } from "../conversation-turn-reconciliation"; import { replayPendingConversationDeletions } from "../conversation-lifecycle"; +import { dispatchAgentHostOffers } from "../agent-host-service"; export type ChatViewMode = "compact" | "expanded"; @@ -67,6 +65,7 @@ function toProjectable(messages: UIMessage[]) { return messages.map((m) => ({ id: m.id, senderId: m.senderId, + replyToMessageId: m.replyToMessageId, role: m.role as "user" | "assistant" | "system", content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), @@ -108,13 +107,6 @@ function buildResponderPrompt( * carried too so each offer's prompt names the same participants and repeats * the same permission to pass. */ -interface OpenFloorOffer { - requestMessages: Map; - room: Member[]; - /** Everyone this batch went to, so each knows it was not asked alone. */ - offered: OfferedPeer[]; -} - // Selected content structure export interface SelectedContent { text?: string; @@ -136,6 +128,7 @@ interface ChatContextType { error: Error | undefined; selectedContent: SelectedContent | null; attachments: File[]; + replyTargetId: string | null; // View mode management viewMode: ChatViewMode; @@ -152,6 +145,7 @@ interface ChatContextType { toolsError: string | null; setInput: (input: string) => void; + setReplyTarget: (messageId: string | null) => void; sendMessage: (messageOrFiles?: string | File[], extraFiles?: File[]) => void; stopGeneration: () => void; editMessage: (message: Message, newContent: string) => void; @@ -210,6 +204,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const [selectedContent, setSelectedContent] = useState(null); const [attachments, setAttachments] = useState([]); + const [replyTargetId, setReplyTargetId] = useState(null); const [viewMode, setViewMode] = useState("compact"); // Use shared Zustand store for conversation ID to sync with sidebar selection @@ -226,6 +221,10 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ ); }, [currentConversationId]); + useEffect(() => { + setReplyTargetId(null); + }, [currentConversationId]); + // MCP Tools state - moved from separate store for simplicity const [availableTools, setAvailableTools] = useState([]); const [mcpServers, setMcpServers] = useState([]); @@ -278,7 +277,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ // opened, plus the prompt each offered agent was to answer it with. Turns // commit one at a time, so without this the second agent would read the // first one's answer and agree with it instead of judging the question. - const openFloorOfferRef = useRef(null); // Keep refs in sync useEffect(() => { @@ -378,9 +376,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ // offered, not the transcript its colleagues have since written into. A // mention chain is the opposite case: being named *is* a reply to what was // just said, so it reads live. - const offer = openFloorOfferRef.current; - const offeredMessages = offer?.requestMessages.get(nextResponderId); - if (!pendingInvokesRef.current.length) openFloorOfferRef.current = null; + const offeredMessages = undefined; const nextMember = members.find((m) => m.id === nextResponderId); if (!nextMember?.agentId) return; @@ -456,10 +452,10 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ systemPrompt: buildResponderPrompt( agent, nextResponderId, - offeredMessages ? (offer?.room ?? members) : members, + members, relayChannel?.name, offeredMessages !== undefined, - offer?.offered ?? [], + [], ), }, responderId: nextResponderId, @@ -759,6 +755,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ if (!messageText && !selectedContent && filesToSend.length === 0) return; const requestedSelection = getConversationSelectionToken(); + const requestedReplyTargetId = replyTargetId; // Handle selected content (text only) if (selectedContent) { @@ -776,6 +773,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ role: "user", content: messageText, senderId: LOCAL_HUMAN_MEMBER_ID, + replyToMessageId: requestedReplyTargetId ?? undefined, }; const sendMessageWithAttachments = async () => { @@ -849,6 +847,131 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ } const conversationIdToUse = selection.conversationId; const { conversation, messages: persistedMessages } = sendContext; + if ( + requestedReplyTargetId && + !persistedMessages.some( + (persisted) => persisted.id === requestedReplyTargetId, + ) + ) { + throw new Error( + "The message you were replying to is no longer in this conversation.", + ); + } + const channel = await db.channels + .where("conversationId") + .equals(conversationIdToUse) + .first(); + if (channel) { + const memberRows = await db.members.bulkGet(channel.memberIds); + const channelMembers = memberRows.filter( + (member): member is Member => member !== undefined, + ); + const mentionedMemberIds = parseMentions( + messageText, + channelMembers, + ); + const replyParent = requestedReplyTargetId + ? persistedMessages.find( + (persisted) => persisted.id === requestedReplyTargetId, + ) + : undefined; + const routed = routeMessage({ + message: { + senderId: LOCAL_HUMAN_MEMBER_ID, + content: messageText, + }, + members: channelMembers, + replyToSenderId: replyParent?.senderId, + defaultAgentMemberId: + channel.kind === "dm" ? channel.defaultAgentMemberId : null, + openFloor: channel.kind === "channel", + chain: null, + }); + const persistedId = await addMessage(conversationIdToUse, { + role: "user", + content: messageText, + senderId: LOCAL_HUMAN_MEMBER_ID, + mentions: mentionedMemberIds, + replyToMessageId: requestedReplyTargetId ?? undefined, + status: "completed", + experimental_attachments: message.experimental_attachments?.map( + (attachment) => ({ + url: attachment.url, + name: attachment.name ?? "", + contentType: attachment.contentType ?? "", + }), + ), + }); + const persisted = await db.messages.get(persistedId); + if (!persisted) { + throw new Error("The channel message could not be persisted."); + } + chatAPI.setMessages([ + ...persistedMessages, + { + id: persisted.id, + role: "user", + content: persisted.content, + senderId: persisted.senderId, + mentions: persisted.mentions, + replyToMessageId: persisted.replyToMessageId, + experimental_attachments: message.experimental_attachments, + createdAt: persisted.createdAt, + }, + ]); + chatAPI.setInput(""); + clearAttachments(); + setReplyTargetId(null); + + if (routed.invoke.length > 0) { + const targets = routed.invoke.flatMap((memberId) => { + const member = channelMembers.find( + (candidate) => candidate.id === memberId, + ); + return member?.agentId + ? [{ agentId: member.agentId, memberId }] + : []; + }); + const directReplyToAgent = + replyParent?.senderId && + channelMembers.some( + (member) => + member.id === replyParent.senderId && + member.kind === "agent", + ); + const mode = + channel.kind === "channel" && + mentionedMemberIds.length === 0 && + !directReplyToAgent + ? "open-floor" + : "direct"; + const recentContextIds = persistedMessages + .slice(requestedReplyTargetId ? -498 : -499) + .map((persistedMessage) => persistedMessage.id); + const contextMessageIds = [ + ...(requestedReplyTargetId && + !recentContextIds.includes(requestedReplyTargetId) + ? [requestedReplyTargetId] + : []), + ...recentContextIds, + persisted.id, + ]; + void dispatchAgentHostOffers({ + channelId: channel.id, + conversationId: channel.conversationId, + triggerMessageId: persisted.id, + contextMessageIds, + mode, + offeredAgentMemberIds: routed.invoke, + targets, + chain: routed.chain, + }).catch((error) => { + console.error("Could not enqueue durable agent offers:", error); + }); + } + return; + } + const providerId = resolveLocalAIProviderId( sendContext.providerSelection.configId, ); @@ -856,146 +979,28 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const members = await db.members.toArray(); const mentionedMemberIds = parseMentions(messageText, members); message.mentions = mentionedMemberIds; - const channelForSend = await db.channels - .where("conversationId") - .equals(conversationIdToUse) - .first(); - // In a channel the floor is open — everyone in the room is offered - // the turn and passes if they have nothing to say. A plain 1:1 chat - // has one counterpart, who always answers. - const channelMembers = channelForSend - ? members.filter((member) => - channelForSend.memberIds.includes(member.id), - ) - : members; + // Workspace channels returned above after handing collaboration + // semantics to the durable Host. Reaching here means this is a + // legacy/plain 1:1 conversation with one selected responder. + const channelMembers = members; const routed = routeMessage({ message: { senderId: LOCAL_HUMAN_MEMBER_ID, content: messageText, }, members: channelMembers, - defaultAgentMemberId: channelForSend - ? null - : selectedAgent - ? memberIdForAgent(selectedAgent.id) - : null, - openFloor: !!channelForSend, + defaultAgentMemberId: selectedAgent + ? memberIdForAgent(selectedAgent.id) + : null, + openFloor: false, chain: null, }); + chainRef.current = routed.chain; const [firstResponder, ...queued] = routed.invoke; pendingInvokesRef.current = queued; - // Everyone offered the floor judges the room as it stands *now* — - // before any of them has answered. Projected once, up front, so the - // colleague that commits second is not reading the first one's reply. - const openFloor = !!channelForSend && mentionedMemberIds.length === 0; - const offeredPeers: OfferedPeer[] = openFloor - ? await Promise.all( - routed.invoke.map(async (id) => { - const peer = members.find((member) => member.id === id); - const peerAgent = peer?.agentId - ? await db.agents.get(peer.agentId) - : undefined; - return { - id, - name: peer?.name ?? "A colleague", - description: peerAgent?.description || undefined, - }; - }), - ) - : []; - openFloorOfferRef.current = openFloor - ? { - requestMessages: projectOpenFloor( - routed.invoke, - [ - ...toProjectable(persistedMessages), - { - senderId: LOCAL_HUMAN_MEMBER_ID, - role: "user", - content: messageText, - }, - ], - members, - ), - room: channelMembers, - offered: offeredPeers, - } - : null; - // Open floor: everyone offered the message runs at once. They reserve - // no rows and speak through `send_message`, so there is nothing for - // them to queue behind — a room where three people were asked does - // not make them answer in series. - if (openFloor && routed.invoke.length > 0) { - const offer = openFloorOfferRef.current; - const turns = ( - await Promise.all( - routed.invoke.map(async (id) => { - const member = members.find((m) => m.id === id); - const dbAgent = member?.agentId - ? await db.agents.get(member.agentId) - : undefined; - if (!dbAgent || !offer) return undefined; - return { - memberId: id, - agentId: dbAgent.id, - conversationId: conversationIdToUse, - providerId: dbAgent.providerId - ? resolveLocalAIProviderId(dbAgent.providerId) - : providerId, - modelId: - (dbAgent.modelId ?? selectedModelId) === - DEFAULT_LOCAL_AI_MODEL_ID - ? undefined - : (dbAgent.modelId ?? selectedModelId), - systemPrompt: buildResponderPrompt( - dbAgent, - id, - channelMembers, - channelForSend?.name, - true, - offeredPeers, - channelForSend?.id, - ), - requestMessages: offer.requestMessages.get(id) ?? [], - }; - }), - ) - ).filter((turn): turn is NonNullable => !!turn); - - if (turns.length > 0) { - // `chatAPI.send` normally persists the human's message on the way - // to running a turn; this path does not use it, so the message is - // written here or it would never reach the room. - const userMessageId = await addMessage(conversationIdToUse, { - role: "user", - content: messageText, - senderId: LOCAL_HUMAN_MEMBER_ID, - mentions: mentionedMemberIds, - status: "completed", - }); - chatAPI.setMessages([ - ...persistedMessages, - { - id: userMessageId, - role: "user", - content: messageText, - senderId: LOCAL_HUMAN_MEMBER_ID, - createdAt: new Date(), - }, - ]); - chatAPI.setInput(""); - clearAttachments(); - pendingInvokesRef.current = []; - void dispatchOpenFloor(turns).then((results) => { - const failed = results.filter((r) => r.error); - if (failed.length) { - console.error("Open floor turns failed:", failed); - } - }); - return; - } - } + const openFloor = false; + const offeredPeers: OfferedPeer[] = []; const responderMember = members.find( (member) => member.id === firstResponder, @@ -1047,7 +1052,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ responder, responderMemberId, channelMembers, - channelForSend?.name, + undefined, openFloor, offeredPeers, ), @@ -1058,10 +1063,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ // row is reserved and staying quiet leaves no trace. speaksViaTool: openFloor, requestMessages: responderMemberId - ? (openFloorOfferRef.current?.requestMessages.get( - responderMemberId, - ) ?? - projectFor( + ? projectFor( responderMemberId, [ ...toProjectable(persistedMessages), @@ -1072,7 +1074,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ }, ], members, - )) + ) : undefined, }, persistedMessages, @@ -1081,6 +1083,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ if (accepted) { chatAPI.setInput(""); clearAttachments(); + setReplyTargetId(null); } else { activeConversationIdRef.current = null; activeTurnIdRef.current = null; @@ -1099,6 +1102,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ selectedContent, attachments, clearAttachments, + replyTargetId, fileToAttachment, setCurrentConversationId, selectedAgent, @@ -1319,8 +1323,8 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ activeConversationIdRef.current = null; activeTurnIdRef.current = null; pendingInvokesRef.current = []; - openFloorOfferRef.current = null; setSelectedContent(null); + setReplyTargetId(null); clearAttachments(); setCurrentConversationId(null); // Reset to compact mode when clearing chat @@ -1349,7 +1353,9 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ setViewMode, toggleViewMode, currentConversationId, + replyTargetId, setInput, + setReplyTarget: setReplyTargetId, sendMessage, stopGeneration, editMessage, diff --git a/packages/app/src/renderer/libs/stores/typing-store.test.ts b/packages/app/src/renderer/libs/stores/typing-store.test.ts new file mode 100644 index 00000000..194eb076 --- /dev/null +++ b/packages/app/src/renderer/libs/stores/typing-store.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useTypingStore } from "./typing-store"; + +describe("typing store conversation isolation", () => { + beforeEach(() => { + useTypingStore.setState({ typing: {} }); + }); + + it("returns only the members typing in the requested conversation", () => { + const store = useTypingStore.getState(); + store.startTyping("request-dm", "agent:sage", "conversation:dm:sage"); + store.startTyping("request-channel", "agent:patch", "conversation:general"); + + expect( + useTypingStore.getState().typingMemberIds("conversation:dm:sage"), + ).toEqual(["agent:sage"]); + expect( + useTypingStore.getState().typingMemberIds("conversation:general"), + ).toEqual(["agent:patch"]); + }); + + it("deduplicates one member's concurrent requests within a conversation", () => { + const store = useTypingStore.getState(); + store.startTyping("request-1", "agent:sage", "conversation:dm:sage"); + store.startTyping("request-2", "agent:sage", "conversation:dm:sage"); + + expect( + useTypingStore.getState().typingMemberIds("conversation:dm:sage"), + ).toEqual(["agent:sage"]); + + store.stopTyping("request-1"); + expect( + useTypingStore.getState().typingMemberIds("conversation:dm:sage"), + ).toEqual(["agent:sage"]); + store.stopTyping("request-2"); + expect( + useTypingStore.getState().typingMemberIds("conversation:dm:sage"), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/renderer/libs/stores/typing-store.ts b/packages/app/src/renderer/libs/stores/typing-store.ts index c9edb8de..342bcccf 100644 --- a/packages/app/src/renderer/libs/stores/typing-store.ts +++ b/packages/app/src/renderer/libs/stores/typing-store.ts @@ -14,18 +14,27 @@ import { create } from "zustand"; * starts a turn is also responsible for clearing it. */ interface TypingState { - /** requestId -> the member id that is composing a message. */ - typing: Record; - startTyping: (requestId: string, memberId: string) => void; + /** requestId -> who is composing and which conversation may display it. */ + typing: Record; + startTyping: ( + requestId: string, + memberId: string, + conversationId: string, + ) => void; stopTyping: (requestId: string) => void; - typingMemberIds: () => string[]; + typingMemberIds: (conversationId: string) => string[]; } export const useTypingStore = create((set, get) => ({ typing: {}, - startTyping: (requestId, memberId) => - set((state) => ({ typing: { ...state.typing, [requestId]: memberId } })), + startTyping: (requestId, memberId, conversationId) => + set((state) => ({ + typing: { + ...state.typing, + [requestId]: { memberId, conversationId }, + }, + })), stopTyping: (requestId) => set((state) => { @@ -35,5 +44,11 @@ export const useTypingStore = create((set, get) => ({ return { typing: next }; }), - typingMemberIds: () => [...new Set(Object.values(get().typing))], + typingMemberIds: (conversationId) => [ + ...new Set( + Object.values(get().typing) + .filter((entry) => entry.conversationId === conversationId) + .map((entry) => entry.memberId), + ), + ], })); diff --git a/packages/app/src/renderer/libs/web-bridge/ipc-shim.test.ts b/packages/app/src/renderer/libs/web-bridge/ipc-shim.test.ts index a814c876..42e01d15 100644 --- a/packages/app/src/renderer/libs/web-bridge/ipc-shim.test.ts +++ b/packages/app/src/renderer/libs/web-bridge/ipc-shim.test.ts @@ -38,6 +38,7 @@ class FakeSocket { describe("web bridge ipc shim", () => { it("waits for the event socket before invoking, so no stream event is lost", async () => { vi.stubGlobal("WebSocket", FakeSocket); + vi.stubGlobal("sessionStorage", undefined); FakeSocket.instances = []; const fetchMock = vi.fn(async () => ({ diff --git a/packages/app/src/renderer/libs/web-bridge/ipc-shim.ts b/packages/app/src/renderer/libs/web-bridge/ipc-shim.ts index 840d537c..40f912f5 100644 --- a/packages/app/src/renderer/libs/web-bridge/ipc-shim.ts +++ b/packages/app/src/renderer/libs/web-bridge/ipc-shim.ts @@ -65,10 +65,12 @@ export function createWebBridgeIPC(config: WebBridgeConfig): RendererIPCLike { // to the bridge — orphaning the sender an in-flight turn is streaming to, so // the reply lands nowhere until a manual refresh re-reads it from disk. const clientId = ((): string => { - const stored = sessionStorage.getItem(CLIENT_ID_KEY); + const storage = + typeof sessionStorage === "undefined" ? null : sessionStorage; + const stored = storage?.getItem(CLIENT_ID_KEY); if (stored) return stored; const created = globalThis.crypto.randomUUID(); - sessionStorage.setItem(CLIENT_ID_KEY, created); + storage?.setItem(CLIENT_ID_KEY, created); return created; })(); let socket: WebSocket | null = null; diff --git a/packages/app/src/renderer/libs/workspace-perception.ts b/packages/app/src/renderer/libs/workspace-perception.ts index 7dd4e4cc..5026ec53 100644 --- a/packages/app/src/renderer/libs/workspace-perception.ts +++ b/packages/app/src/renderer/libs/workspace-perception.ts @@ -24,6 +24,7 @@ import { WORKSPACE_MESSAGE_LIMIT_MAX, WORKSPACE_QUERY_INTERACTION, WORKSPACE_QUERY_RESULT_BUDGET, + WORKSPACE_REPLY_EXCERPT_MAX, } from "@/shared/types/workspace-perception"; import type { Channel, Member } from "@/shared/types/workspace"; import { db, type Message } from "./db"; @@ -83,15 +84,34 @@ function senderName( function toChannelMessage( message: Message, members: Map, + messagesById: Map, ): WorkspaceChannelMessage { const content = message.content.length > WORKSPACE_MESSAGE_CONTENT_MAX ? `${message.content.slice(0, WORKSPACE_MESSAGE_CONTENT_MAX)}…` : message.content; + const parent = message.replyToMessageId + ? messagesById.get(message.replyToMessageId) + : undefined; + const parentSender = parent ? senderName(parent, members) : undefined; + const parentContent = parent?.content.replace(/\s+/g, " ").trim(); + const replyTo = message.replyToMessageId + ? { + messageId: message.replyToMessageId, + senderId: parentSender?.senderId ?? null, + senderName: parentSender?.senderName ?? null, + content: parentContent + ? parentContent.length > WORKSPACE_REPLY_EXCERPT_MAX + ? `${parentContent.slice(0, WORKSPACE_REPLY_EXCERPT_MAX)}…` + : parentContent + : null, + } + : undefined; return { id: message.id, ...senderName(message, members), content, + ...(replyTo ? { replyTo } : {}), createdAt: new Date(message.createdAt).toISOString(), }; } @@ -160,10 +180,13 @@ async function readChannel( const ordered = transcript .filter((message) => message.role !== "system") .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + const messagesById = new Map(ordered.map((message) => [message.id, message])); const window = ordered.slice( -Math.min(Math.max(limit, 1), WORKSPACE_MESSAGE_LIMIT_MAX), ); - const messages = window.map((message) => toChannelMessage(message, members)); + const messages = window.map((message) => + toChannelMessage(message, members, messagesById), + ); let truncated = window.length < ordered.length; const view = (): WorkspaceChannelView => ({ @@ -209,6 +232,23 @@ async function sendMessage( if (!channel || !canViewChannel(query.viewerMemberId, channel)) { return notFound(query.channelId); } + if (query.replyToMessageId) { + const target = await db.messages.get(query.replyToMessageId); + if ( + !target || + target.conversationId !== channel.conversationId || + (target.role !== "user" && target.role !== "assistant") + ) { + return { + ok: false, + error: { + code: "REPLY_TARGET_NOT_FOUND", + message: + "The message being replied to is not available in that channel.", + }, + }; + } + } if (!sendMessageHandler) { return { ok: false, diff --git a/packages/app/src/renderer/types/chat.ts b/packages/app/src/renderer/types/chat.ts index e60cccdb..66c60bde 100644 --- a/packages/app/src/renderer/types/chat.ts +++ b/packages/app/src/renderer/types/chat.ts @@ -23,6 +23,8 @@ export interface UIMessage { mentions?: string[]; /** emoji -> Member.id[] who reacted. Only set on persisted messages. */ reactions?: Record; + /** Message.id this row directly replies to. */ + replyToMessageId?: string; createdAt?: Date; parts?: MessagePart[]; experimental_attachments?: Attachment[]; diff --git a/packages/app/src/shared/types/agent-host.ts b/packages/app/src/shared/types/agent-host.ts new file mode 100644 index 00000000..01701562 --- /dev/null +++ b/packages/app/src/shared/types/agent-host.ts @@ -0,0 +1,109 @@ +import type { LocalAIChatRequest, LocalAIStreamEvent } from "./local-ai"; + +export type AgentHostJobStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled" + | "interrupted"; + +export interface AgentHostChain { + /** Agent-authored channel messages since the originating human message. */ + hops: number; + /** Member ids already invoked by this chain, in order. */ + invoked: string[]; +} + +export type AgentHostOfferMode = "open-floor" | "direct"; + +export interface AgentHostTarget { + /** Stable Agent entity id. Electron main binds the runtime sandbox to it. */ + agentId: string; + /** Stable workspace participant id used for authorship and native sessions. */ + memberId: string; +} + +export interface AgentHostDispatch { + channelId: string; + conversationId: string; + triggerMessageId: string; + /** Exact transcript boundary shared by everyone offered this message. */ + contextMessageIds: string[]; + /** Direct mentions must answer; an open floor may stay quiet. */ + mode: AgentHostOfferMode; + /** Everyone who received the same offer, used only to explain the room. */ + offeredAgentMemberIds: string[]; + targets: AgentHostTarget[]; + chain: AgentHostChain; +} + +export interface AgentHostJob { + id: string; + channelId: string; + conversationId: string; + triggerMessageId: string; + contextMessageIds: string[]; + mode: AgentHostOfferMode; + offeredAgentMemberIds: string[]; + agentId: string; + agentMemberId: string; + chain: AgentHostChain; + status: AgentHostJobStatus; + attempts: number; + requestId?: string; + turnId?: string; + error?: string; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; +} + +export interface PreparedAgentHostTurn { + request: LocalAIChatRequest; +} + +export interface AgentHostRendererRequest { + id: string; + kind: "prepare-turn"; + job: AgentHostJob; +} + +export interface AgentHostRendererResponse { + requestId: string; + success: boolean; + data?: unknown; + error?: string; +} + +export type AgentHostEvent = + | { + type: "job"; + job: AgentHostJob; + } + | { + type: "stream"; + jobId: string; + event: LocalAIStreamEvent; + }; + +export interface IAgentHostAPI { + ready(): Promise<{ success: boolean; error?: string }>; + enqueue( + dispatch: AgentHostDispatch, + ): Promise<{ success: boolean; jobs?: AgentHostJob[]; error?: string }>; + listJobs(): Promise<{ + success: boolean; + jobs?: AgentHostJob[]; + error?: string; + }>; + cancel( + jobId: string, + ): Promise<{ success: boolean; cancelled?: boolean; error?: string }>; + respond( + response: AgentHostRendererResponse, + ): Promise<{ success: boolean; accepted?: boolean; error?: string }>; + onRequest(callback: (request: AgentHostRendererRequest) => void): () => void; + onEvent(callback: (event: AgentHostEvent) => void): () => void; +} diff --git a/packages/app/src/shared/types/electron.d.ts b/packages/app/src/shared/types/electron.d.ts index 15ec5970..387674c7 100644 --- a/packages/app/src/shared/types/electron.d.ts +++ b/packages/app/src/shared/types/electron.d.ts @@ -3,6 +3,7 @@ import { WindowSizeConfig } from "@/electron/windows/window-size"; import type { ILocalAIAPI } from "./local-ai"; import type { IMcpAPI } from "./mcp"; +import type { IAgentHostAPI } from "./agent-host"; // Enum for window types export type WindowType = "settings" | "history" | "main" | "chat"; @@ -109,5 +110,6 @@ declare global { envApi: IEnvAPI; logger: WindowLogger; localAI: ILocalAIAPI; + agentHost?: IAgentHostAPI; } } diff --git a/packages/app/src/shared/types/workspace-perception.ts b/packages/app/src/shared/types/workspace-perception.ts index ec4191ec..b9cad690 100644 --- a/packages/app/src/shared/types/workspace-perception.ts +++ b/packages/app/src/shared/types/workspace-perception.ts @@ -30,6 +30,7 @@ export const WORKSPACE_QUERY_RESULT_BUDGET = 18_000; export const WORKSPACE_MESSAGE_LIMIT_DEFAULT = 30; export const WORKSPACE_MESSAGE_LIMIT_MAX = 100; export const WORKSPACE_MESSAGE_CONTENT_MAX = 2_000; +export const WORKSPACE_REPLY_EXCERPT_MAX = 280; export interface WorkspaceListChannelsQuery { kind: "list_channels"; @@ -54,6 +55,15 @@ export interface WorkspaceSendMessageQuery { viewerMemberId: string; channelId: string; content: string; + /** Existing message in the destination channel this directly answers. */ + replyToMessageId?: string; + /** Renderer-owned lifecycle context; never accepted from a model tool input. */ + agentHost?: { + jobId: string; + triggerMessageId: string; + contextMessageIds: string[]; + chain: { hops: number; invoked: string[] }; + }; } export type WorkspaceQuery = @@ -86,6 +96,13 @@ export interface WorkspaceChannelMessage { senderId: string | null; senderName: string; content: string; + replyTo?: { + messageId: string; + /** Null when the referenced row or its sender is no longer available. */ + senderId: string | null; + senderName: string | null; + content: string | null; + }; createdAt: string; }