fix(app): resolve create agent dictation confirm handler timing issue
Fixed a state management bug where clicking the checkmark in create agent dictation mode would not trigger processing. The issue was that setIsDictationProcessing(true) was called after stopping the recorder, causing a race condition where the UI never showed the processing state. Changes: - Move setIsDictationProcessing(true) to execute immediately at the start of the confirm handler - Add proper cleanup when audioData is null - Ensure UI shows loading spinner when checkmark is clicked This aligns the create agent dictation behavior with the working agent chat dictation implementation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -983,16 +983,20 @@ function AgentFlowModal({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set processing state BEFORE stopping to ensure UI updates properly
|
||||||
|
setIsDictationProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioData = await dictationRecorder.stop();
|
const audioData = await dictationRecorder.stop();
|
||||||
setIsDictating(false);
|
setIsDictating(false);
|
||||||
setDictationVolume(0);
|
setDictationVolume(0);
|
||||||
if (!audioData) {
|
if (!audioData) {
|
||||||
|
setIsDictationProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestId = generateMessageId();
|
const requestId = generateMessageId();
|
||||||
dictationRequestIdRef.current = requestId;
|
dictationRequestIdRef.current = requestId;
|
||||||
setIsDictationProcessing(true);
|
|
||||||
await sendAgentAudio(
|
await sendAgentAudio(
|
||||||
DICTATION_AGENT_ID,
|
DICTATION_AGENT_ID,
|
||||||
audioData,
|
audioData,
|
||||||
|
|||||||
@@ -1,55 +1,131 @@
|
|||||||
import { create } from "zustand";
|
import { useSyncExternalStore } from "react";
|
||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
import type { SessionContextValue } from "@/contexts/session-context";
|
||||||
|
|
||||||
// SessionData mirrors SessionContextValue so consumers can subscribe to a single source of truth.
|
// SessionData mirrors SessionContextValue so consumers can subscribe to a single source of truth.
|
||||||
export type SessionData = SessionContextValue;
|
export type SessionData = SessionContextValue;
|
||||||
|
|
||||||
interface SessionStore {
|
interface SessionStoreState {
|
||||||
sessions: Record<string, SessionData>;
|
sessions: Record<string, SessionData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStore extends SessionStoreState {
|
||||||
setSession: (serverId: string, data: SessionData) => void;
|
setSession: (serverId: string, data: SessionData) => void;
|
||||||
updateSession: (serverId: string, partial: Partial<SessionData>) => void;
|
updateSession: (serverId: string, partial: Partial<SessionData>) => void;
|
||||||
clearSession: (serverId: string) => void;
|
clearSession: (serverId: string) => void;
|
||||||
getSession: (serverId: string) => SessionData | undefined;
|
getSession: (serverId: string) => SessionData | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionStore>((set, get) => ({
|
type SessionListener = () => void;
|
||||||
sessions: {},
|
|
||||||
setSession: (serverId, data) => {
|
let storeState: SessionStoreState = { sessions: {} };
|
||||||
set((state) => ({
|
const listeners = new Set<SessionListener>();
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStoreState = (updater: (prev: SessionStoreState) => SessionStoreState) => {
|
||||||
|
const next = updater(storeState);
|
||||||
|
if (next === storeState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeState = next;
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSession: SessionStore["setSession"] = (serverId, data) => {
|
||||||
|
updateStoreState((prev) => {
|
||||||
|
if (prev.sessions[serverId] === data) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
sessions: {
|
sessions: {
|
||||||
...state.sessions,
|
...prev.sessions,
|
||||||
[serverId]: data,
|
[serverId]: data,
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
},
|
});
|
||||||
updateSession: (serverId, partial) => {
|
};
|
||||||
set((state) => {
|
|
||||||
const existing = state.sessions[serverId];
|
|
||||||
const next: SessionData | undefined = existing
|
|
||||||
? { ...existing, ...partial, serverId }
|
|
||||||
: (partial.serverId ? { ...(partial as SessionData), serverId } : undefined);
|
|
||||||
|
|
||||||
if (!next) {
|
const updateSession: SessionStore["updateSession"] = (serverId, partial) => {
|
||||||
return state;
|
updateStoreState((prev) => {
|
||||||
}
|
const existing = prev.sessions[serverId];
|
||||||
|
const next: SessionData | undefined = existing
|
||||||
|
? { ...existing, ...partial, serverId }
|
||||||
|
: partial.serverId
|
||||||
|
? ({ ...(partial as SessionData), serverId })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
if (!next) {
|
||||||
sessions: {
|
return prev;
|
||||||
...state.sessions,
|
}
|
||||||
[serverId]: next,
|
|
||||||
},
|
if (existing && shallowEqual(existing, next)) {
|
||||||
};
|
return prev;
|
||||||
});
|
}
|
||||||
},
|
|
||||||
clearSession: (serverId) => {
|
return {
|
||||||
set((state) => {
|
sessions: {
|
||||||
if (!(serverId in state.sessions)) {
|
...prev.sessions,
|
||||||
return state;
|
[serverId]: next,
|
||||||
}
|
},
|
||||||
const nextSessions = { ...state.sessions };
|
};
|
||||||
delete nextSessions[serverId];
|
});
|
||||||
return { sessions: nextSessions };
|
};
|
||||||
});
|
|
||||||
},
|
const clearSession: SessionStore["clearSession"] = (serverId) => {
|
||||||
getSession: (serverId) => get().sessions[serverId],
|
updateStoreState((prev) => {
|
||||||
}));
|
if (!(serverId in prev.sessions)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const nextSessions = { ...prev.sessions };
|
||||||
|
delete nextSessions[serverId];
|
||||||
|
return { sessions: nextSessions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSession: SessionStore["getSession"] = (serverId) => {
|
||||||
|
return storeState.sessions[serverId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribe = (listener: SessionListener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSnapshot = (): SessionStore => ({
|
||||||
|
sessions: storeState.sessions,
|
||||||
|
setSession,
|
||||||
|
updateSession,
|
||||||
|
clearSession,
|
||||||
|
getSession,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shallowEqual = (left: SessionData, right: SessionData): boolean => {
|
||||||
|
if (left === right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const leftEntries = Object.entries(left);
|
||||||
|
const rightEntries = Object.entries(right);
|
||||||
|
if (leftEntries.length !== rightEntries.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const [key, value] of leftEntries) {
|
||||||
|
if (right[key as keyof SessionData] !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSessionStore<T>(selector: (state: SessionStore) => T): T {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => selector(buildSnapshot()),
|
||||||
|
() => selector(buildSnapshot())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export type AgentManagerOptions = {
|
|||||||
idFactory?: () => string;
|
idFactory?: () => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WaitForAgentOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WaitForAgentResult = {
|
||||||
|
status: AgentLifecycleStatus;
|
||||||
|
permission: AgentPermissionRequest | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ManagedAgent = {
|
type ManagedAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -97,6 +106,28 @@ type SubscriptionRecord = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
|
const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
|
||||||
|
const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
||||||
|
"initializing",
|
||||||
|
"running",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
||||||
|
return BUSY_STATUSES.includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAbortError(
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
fallbackMessage: string
|
||||||
|
): Error {
|
||||||
|
const reason = signal?.reason;
|
||||||
|
const message =
|
||||||
|
typeof reason === "string"
|
||||||
|
? reason
|
||||||
|
: reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: fallbackMessage;
|
||||||
|
return Object.assign(new Error(message), { name: "AbortError" });
|
||||||
|
}
|
||||||
|
|
||||||
export class AgentManager {
|
export class AgentManager {
|
||||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||||
@@ -460,6 +491,105 @@ export class AgentManager {
|
|||||||
await this.primeHistory(agent);
|
await this.primeHistory(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForAgentEvent(
|
||||||
|
agentId: string,
|
||||||
|
options?: WaitForAgentOptions
|
||||||
|
): Promise<WaitForAgentResult> {
|
||||||
|
const snapshot = this.getAgent(agentId);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const immediatePermission = snapshot.pendingPermissions[0] ?? null;
|
||||||
|
if (immediatePermission) {
|
||||||
|
return { status: snapshot.status, permission: immediatePermission };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAgentBusy(snapshot.status)) {
|
||||||
|
return { status: snapshot.status, permission: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||||
|
let currentStatus: AgentLifecycleStatus = snapshot.status;
|
||||||
|
const cleanupFns: Array<() => void> = [];
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
while (cleanupFns.length) {
|
||||||
|
const fn = cleanupFns.pop();
|
||||||
|
try {
|
||||||
|
fn?.();
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (permission: AgentPermissionRequest | null) => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ status: currentStatus, permission });
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = this.subscribe(
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "agent_state") {
|
||||||
|
currentStatus = event.agent.status;
|
||||||
|
const pending = event.agent.pendingPermissions[0] ?? null;
|
||||||
|
if (pending) {
|
||||||
|
finish(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAgentBusy(event.agent.status)) {
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== "agent_stream") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.event.type) {
|
||||||
|
case "permission_requested": {
|
||||||
|
currentStatus = "running";
|
||||||
|
finish(event.event.request);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "turn_completed": {
|
||||||
|
currentStatus = "idle";
|
||||||
|
finish(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "turn_failed": {
|
||||||
|
currentStatus = "error";
|
||||||
|
finish(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ agentId, replayState: true }
|
||||||
|
);
|
||||||
|
cleanupFns.push(unsubscribe);
|
||||||
|
|
||||||
|
if (options?.signal) {
|
||||||
|
const abortHandler = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||||
|
};
|
||||||
|
|
||||||
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||||
|
cleanupFns.push(() =>
|
||||||
|
options.signal?.removeEventListener("abort", abortHandler)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async registerSession(
|
private async registerSession(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { ensureValidJson } from "../json-utils.js";
|
|||||||
import type {
|
import type {
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentPermissionRequest,
|
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type {
|
import type {
|
||||||
AgentLifecycleStatus,
|
|
||||||
AgentManager,
|
AgentManager,
|
||||||
AgentSnapshot,
|
AgentSnapshot,
|
||||||
|
WaitForAgentResult,
|
||||||
} from "./agent-manager.js";
|
} from "./agent-manager.js";
|
||||||
import {
|
import {
|
||||||
AgentPermissionRequestPayloadSchema,
|
AgentPermissionRequestPayloadSchema,
|
||||||
@@ -73,10 +72,6 @@ function startAgentRun(
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
|
||||||
return status === "running" || status === "initializing";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveAgentTitle(
|
async function resolveAgentTitle(
|
||||||
agentRegistry: AgentRegistry,
|
agentRegistry: AgentRegistry,
|
||||||
agentId: string
|
agentId: string
|
||||||
@@ -101,106 +96,6 @@ async function serializeSnapshotWithMetadata(
|
|||||||
return serializeAgentSnapshot(snapshot, { title });
|
return serializeAgentSnapshot(snapshot, { title });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForAgentEvent(
|
|
||||||
agentManager: AgentManager,
|
|
||||||
agentId: string,
|
|
||||||
tracker: WaitForAgentTracker,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<{
|
|
||||||
status: AgentLifecycleStatus;
|
|
||||||
permission: AgentPermissionRequest | null;
|
|
||||||
}> {
|
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
|
||||||
if (!snapshot) {
|
|
||||||
throw new Error(`Agent ${agentId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingPermission = snapshot.pendingPermissions[0] ?? null;
|
|
||||||
if (existingPermission) {
|
|
||||||
return { status: snapshot.status, permission: existingPermission };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAgentBusy(snapshot.status)) {
|
|
||||||
return { status: snapshot.status, permission: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let currentStatus: AgentLifecycleStatus = snapshot.status;
|
|
||||||
const cleanupFns: Array<() => void> = [];
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
while (cleanupFns.length) {
|
|
||||||
const fn = cleanupFns.pop();
|
|
||||||
try {
|
|
||||||
fn?.();
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = (permission: AgentPermissionRequest | null) => {
|
|
||||||
cleanup();
|
|
||||||
resolve({ status: currentStatus, permission });
|
|
||||||
};
|
|
||||||
|
|
||||||
const unregister = tracker.register(agentId, (reason) => {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
Object.assign(
|
|
||||||
new Error(reason ?? "wait_for_agent cancelled"),
|
|
||||||
{
|
|
||||||
name: "AbortError",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
cleanupFns.push(unregister);
|
|
||||||
|
|
||||||
const unsubscribe = agentManager.subscribe(
|
|
||||||
(event) => {
|
|
||||||
if (event.type === "agent_state") {
|
|
||||||
currentStatus = event.agent.status;
|
|
||||||
if (!isAgentBusy(event.agent.status)) {
|
|
||||||
const pending = event.agent.pendingPermissions[0] ?? null;
|
|
||||||
finish(pending);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === "agent_stream" &&
|
|
||||||
event.event.type === "permission_requested"
|
|
||||||
) {
|
|
||||||
currentStatus = "running";
|
|
||||||
finish(event.event.request);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ agentId, replayState: false }
|
|
||||||
);
|
|
||||||
cleanupFns.push(unsubscribe);
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
const abort = () => {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
Object.assign(new Error("wait_for_agent aborted"), {
|
|
||||||
name: "AbortError",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (signal.aborted) {
|
|
||||||
abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
signal.addEventListener("abort", abort, { once: true });
|
|
||||||
cleanupFns.push(() => signal.removeEventListener("abort", abort));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActivityPayload(
|
function buildActivityPayload(
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentId: string
|
agentId: string
|
||||||
@@ -346,23 +241,64 @@ export async function createAgentMcpServer(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ agentId }, { signal }) => {
|
async ({ agentId }, { signal }) => {
|
||||||
const result = await waitForAgentEvent(
|
const abortController = new AbortController();
|
||||||
agentManager,
|
const cleanupFns: Array<() => void> = [];
|
||||||
agentId,
|
|
||||||
waitTracker,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
const activity = buildActivityPayload(agentManager, agentId);
|
|
||||||
|
|
||||||
return {
|
const cleanup = () => {
|
||||||
content: [],
|
while (cleanupFns.length) {
|
||||||
structuredContent: ensureValidJson({
|
const fn = cleanupFns.pop();
|
||||||
agentId,
|
try {
|
||||||
status: result.status,
|
fn?.();
|
||||||
permission: result.permission,
|
} catch {
|
||||||
activity,
|
// ignore cleanup errors
|
||||||
}),
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forwardExternalAbort = () => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
const reason = signal?.reason ?? new Error("wait_for_agent aborted");
|
||||||
|
abortController.abort(reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
forwardExternalAbort();
|
||||||
|
} else {
|
||||||
|
signal.addEventListener("abort", forwardExternalAbort, { once: true });
|
||||||
|
cleanupFns.push(() =>
|
||||||
|
signal.removeEventListener("abort", forwardExternalAbort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = waitTracker.register(agentId, (reason) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
abortController.abort(new Error(reason ?? "wait_for_agent cancelled"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cleanupFns.push(unregister);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: WaitForAgentResult =
|
||||||
|
await agentManager.waitForAgentEvent(agentId, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
const activity = buildActivityPayload(agentManager, agentId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
agentId,
|
||||||
|
status: result.status,
|
||||||
|
permission: result.permission,
|
||||||
|
activity,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,29 @@ type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
|||||||
|
|
||||||
export type ClaudeContentChunk = { type: string; [key: string]: any };
|
export type ClaudeContentChunk = { type: string; [key: string]: any };
|
||||||
|
|
||||||
|
type AgentControlMcpConfig = {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClaudeAgentClientOptions = {
|
||||||
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
agentControlMcp?: AgentControlMcpConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClaudeAgentSessionOptions = {
|
||||||
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
handle?: AgentPersistenceHandle;
|
||||||
|
agentControlMcp?: AgentControlMcpConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_CONTROL_MCP: AgentControlMcpConfig = {
|
||||||
|
url: "http://127.0.0.1:6767/mcp/agents",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Basic bW86Ym8=",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function extractUserMessageText(
|
export function extractUserMessageText(
|
||||||
content: string | ClaudeContentChunk[]
|
content: string | ClaudeContentChunk[]
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -142,11 +165,20 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
readonly provider = "claude" as const;
|
readonly provider = "claude" as const;
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
|
|
||||||
constructor(private readonly defaults?: { agents?: Record<string, AgentDefinition> }) {}
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
private readonly agentControlMcp?: AgentControlMcpConfig;
|
||||||
|
|
||||||
|
constructor(options?: ClaudeAgentClientOptions) {
|
||||||
|
this.defaults = options?.defaults;
|
||||||
|
this.agentControlMcp = options?.agentControlMcp;
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
const claudeConfig = this.assertConfig(config);
|
const claudeConfig = this.assertConfig(config);
|
||||||
return new ClaudeAgentSession(claudeConfig, this.defaults);
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
|
defaults: this.defaults,
|
||||||
|
agentControlMcp: this.agentControlMcp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(
|
async resumeSession(
|
||||||
@@ -160,7 +192,11 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
const mergedConfig = { ...merged, provider: "claude" } as AgentSessionConfig;
|
const mergedConfig = { ...merged, provider: "claude" } as AgentSessionConfig;
|
||||||
const claudeConfig = this.assertConfig(mergedConfig);
|
const claudeConfig = this.assertConfig(mergedConfig);
|
||||||
return new ClaudeAgentSession(claudeConfig, this.defaults, handle);
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
|
defaults: this.defaults,
|
||||||
|
agentControlMcp: this.agentControlMcp,
|
||||||
|
handle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
||||||
@@ -199,6 +235,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private readonly config: ClaudeAgentConfig;
|
private readonly config: ClaudeAgentConfig;
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
|
private readonly agentControlMcp?: AgentControlMcpConfig;
|
||||||
private query: Query | null = null;
|
private query: Query | null = null;
|
||||||
private input: Pushable<SDKUserMessage> | null = null;
|
private input: Pushable<SDKUserMessage> | null = null;
|
||||||
private claudeSessionId: string | null;
|
private claudeSessionId: string | null;
|
||||||
@@ -220,11 +257,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
config: ClaudeAgentConfig,
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> },
|
options?: ClaudeAgentSessionOptions
|
||||||
handle?: AgentPersistenceHandle
|
|
||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.defaults = defaults;
|
this.defaults = options?.defaults;
|
||||||
|
this.agentControlMcp = options?.agentControlMcp;
|
||||||
|
const handle = options?.handle;
|
||||||
this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null;
|
this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null;
|
||||||
this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`;
|
this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`;
|
||||||
this.persistence = handle ?? null;
|
this.persistence = handle ?? null;
|
||||||
@@ -459,9 +497,24 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
...this.config.extra?.claude,
|
...this.config.extra?.claude,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always include the agent-control MCP server so agents can launch other agents
|
||||||
|
const agentControlConfig = this.agentControlMcp ?? DEFAULT_AGENT_CONTROL_MCP;
|
||||||
|
const agentControlMcp: Record<string, ClaudeMcpServerConfig> = {
|
||||||
|
"agent-control": {
|
||||||
|
type: "http",
|
||||||
|
url: agentControlConfig.url,
|
||||||
|
...(agentControlConfig.headers ? { headers: agentControlConfig.headers } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (this.config.mcpServers) {
|
if (this.config.mcpServers) {
|
||||||
base.mcpServers = this.normalizeMcpServers(this.config.mcpServers);
|
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers);
|
||||||
|
// Merge user-provided MCP servers with agent-control, user servers take precedence
|
||||||
|
base.mcpServers = { ...agentControlMcp, ...normalizedUserServers };
|
||||||
|
} else {
|
||||||
|
base.mcpServers = agentControlMcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.model) {
|
if (this.config.model) {
|
||||||
base.model = this.config.model;
|
base.model = this.config.model;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "dotenv/config";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import basicAuth from "express-basic-auth";
|
import basicAuth from "express-basic-auth";
|
||||||
import { createServer as createHTTPServer } from "http";
|
import { createServer as createHTTPServer } from "http";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||||
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
|
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
|
||||||
import { initializeTTS } from "./agent/tts-openai.js";
|
import { initializeTTS } from "./agent/tts-openai.js";
|
||||||
@@ -15,6 +16,16 @@ import {
|
|||||||
attachAgentRegistryPersistence,
|
attachAgentRegistryPersistence,
|
||||||
restorePersistedAgents,
|
restorePersistedAgents,
|
||||||
} from "./persistence-hooks.js";
|
} from "./persistence-hooks.js";
|
||||||
|
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
|
type AgentMcpTransportMap = Map<
|
||||||
|
string,
|
||||||
|
StreamableHTTPServerTransport
|
||||||
|
>;
|
||||||
|
|
||||||
|
const BASIC_AUTH_USERS = { mo: "bo" } as const;
|
||||||
|
|
||||||
function createServer() {
|
function createServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -25,7 +36,7 @@ function createServer() {
|
|||||||
// Basic authentication (skip for /public routes)
|
// Basic authentication (skip for /public routes)
|
||||||
app.use(
|
app.use(
|
||||||
basicAuth({
|
basicAuth({
|
||||||
users: { mo: "bo" },
|
users: BASIC_AUTH_USERS,
|
||||||
challenge: true,
|
challenge: true,
|
||||||
realm: "Voice Assistant",
|
realm: "Voice Assistant",
|
||||||
})
|
})
|
||||||
@@ -66,6 +77,16 @@ function createServer() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const port = parseInt(process.env.PORT || "6767", 10);
|
const port = parseInt(process.env.PORT || "6767", 10);
|
||||||
|
const agentMcpRoute = "/mcp/agents";
|
||||||
|
const agentMcpUrl = `http://127.0.0.1:${port}${agentMcpRoute}`;
|
||||||
|
const [agentMcpUser, agentMcpPassword] =
|
||||||
|
Object.entries(BASIC_AUTH_USERS)[0] ?? [];
|
||||||
|
const agentMcpAuthHeader =
|
||||||
|
agentMcpUser && agentMcpPassword
|
||||||
|
? `Basic ${Buffer.from(
|
||||||
|
`${agentMcpUser}:${agentMcpPassword}`
|
||||||
|
).toString("base64")}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
const httpServer = createHTTPServer(app);
|
const httpServer = createHTTPServer(app);
|
||||||
@@ -74,7 +95,14 @@ async function main() {
|
|||||||
const agentRegistry = new AgentRegistry();
|
const agentRegistry = new AgentRegistry();
|
||||||
const agentManager = new AgentManager({
|
const agentManager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
claude: new ClaudeAgentClient(),
|
claude: new ClaudeAgentClient({
|
||||||
|
agentControlMcp: {
|
||||||
|
url: agentMcpUrl,
|
||||||
|
...(agentMcpAuthHeader
|
||||||
|
? { headers: { Authorization: agentMcpAuthHeader } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
codex: new CodexAgentClient(),
|
codex: new CodexAgentClient(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -84,11 +112,112 @@ async function main() {
|
|||||||
await restorePersistedAgents(agentManager, agentRegistry);
|
await restorePersistedAgents(agentManager, agentRegistry);
|
||||||
console.log("✓ Global agent manager initialized with persisted agents");
|
console.log("✓ Global agent manager initialized with persisted agents");
|
||||||
|
|
||||||
|
const agentMcpServer = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentRegistry,
|
||||||
|
});
|
||||||
|
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||||
|
|
||||||
|
const createAgentMcpTransport = async () => {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
onsessioninitialized: (sessionId) => {
|
||||||
|
agentMcpTransports.set(sessionId, transport);
|
||||||
|
console.log(`[Agent MCP] Session initialized: ${sessionId}`);
|
||||||
|
},
|
||||||
|
onsessionclosed: (sessionId) => {
|
||||||
|
agentMcpTransports.delete(sessionId);
|
||||||
|
console.log(`[Agent MCP] Session closed: ${sessionId}`);
|
||||||
|
},
|
||||||
|
enableDnsRebindingProtection: true,
|
||||||
|
allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||||
|
});
|
||||||
|
transport.onclose = () => {
|
||||||
|
if (transport.sessionId) {
|
||||||
|
agentMcpTransports.delete(transport.sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
transport.onerror = (error) => {
|
||||||
|
console.error("[Agent MCP] Transport error:", error);
|
||||||
|
};
|
||||||
|
await agentMcpServer.connect(transport);
|
||||||
|
return transport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentMcpRequest: express.RequestHandler = async (
|
||||||
|
req,
|
||||||
|
res
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.header("mcp-session-id");
|
||||||
|
let transport = sessionId
|
||||||
|
? agentMcpTransports.get(sessionId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!transport) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.status(400).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: "Missing or invalid MCP session",
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isInitializeRequest(req.body)) {
|
||||||
|
res.status(400).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: "Initialization request expected",
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transport = await createAgentMcpTransport();
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.handleRequest(
|
||||||
|
req as any,
|
||||||
|
res as any,
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Agent MCP] Failed to handle request:", error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: "Internal MCP server error",
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post(agentMcpRoute, handleAgentMcpRequest);
|
||||||
|
app.get(agentMcpRoute, handleAgentMcpRequest);
|
||||||
|
app.delete(agentMcpRoute, handleAgentMcpRequest);
|
||||||
|
console.log(`✓ Agent MCP server mounted at ${agentMcpRoute}`);
|
||||||
|
|
||||||
// Initialize WebSocket server
|
// Initialize WebSocket server
|
||||||
const wsServer = new VoiceAssistantWebSocketServer(
|
const wsServer = new VoiceAssistantWebSocketServer(
|
||||||
httpServer,
|
httpServer,
|
||||||
agentManager,
|
agentManager,
|
||||||
agentRegistry
|
agentRegistry,
|
||||||
|
{
|
||||||
|
agentMcpUrl,
|
||||||
|
agentMcpHeaders: agentMcpAuthHeader
|
||||||
|
? {
|
||||||
|
Authorization: agentMcpAuthHeader,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize OpenAI client
|
// Initialize OpenAI client
|
||||||
|
|||||||
@@ -244,6 +244,59 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
title: z.string().nullable(),
|
title: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function sanitizeOptionalJson(value: unknown): unknown {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => sanitizeOptionalJson(item))
|
||||||
|
.filter((item) => item !== undefined);
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const sanitized = sanitizeOptionalJson(val);
|
||||||
|
if (sanitized !== undefined) {
|
||||||
|
result[key] = sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(result).length ? result : undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeOptionalJsonValue<T>(value: T | undefined): T | undefined {
|
||||||
|
const sanitized = sanitizeOptionalJson(value);
|
||||||
|
return sanitized === undefined ? undefined : (sanitized as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePersistenceHandle(
|
||||||
|
handle: AgentPersistenceHandle | null
|
||||||
|
): AgentPersistenceHandle | null {
|
||||||
|
if (!handle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sanitized: AgentPersistenceHandle = {
|
||||||
|
provider: handle.provider,
|
||||||
|
sessionId: handle.sessionId,
|
||||||
|
};
|
||||||
|
if (handle.nativeHandle !== undefined) {
|
||||||
|
sanitized.nativeHandle = handle.nativeHandle;
|
||||||
|
}
|
||||||
|
const metadata = sanitizeOptionalJsonValue(handle.metadata);
|
||||||
|
if (metadata !== undefined) {
|
||||||
|
sanitized.metadata = metadata;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentStreamEventPayload = z.infer<
|
export type AgentStreamEventPayload = z.infer<
|
||||||
typeof AgentStreamEventPayloadSchema
|
typeof AgentStreamEventPayloadSchema
|
||||||
>;
|
>;
|
||||||
@@ -252,15 +305,35 @@ export function serializeAgentSnapshot(
|
|||||||
snapshot: AgentSnapshot,
|
snapshot: AgentSnapshot,
|
||||||
options?: { title?: string | null }
|
options?: { title?: string | null }
|
||||||
): AgentSnapshotPayload {
|
): AgentSnapshotPayload {
|
||||||
const { createdAt, updatedAt, lastUserMessageAt, ...rest } = snapshot;
|
const payload: AgentSnapshotPayload = {
|
||||||
return {
|
id: snapshot.id,
|
||||||
...rest,
|
provider: snapshot.provider,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
model: snapshot.model,
|
model: snapshot.model,
|
||||||
createdAt: createdAt.toISOString(),
|
createdAt: snapshot.createdAt.toISOString(),
|
||||||
updatedAt: updatedAt.toISOString(),
|
updatedAt: snapshot.updatedAt.toISOString(),
|
||||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
lastUserMessageAt: snapshot.lastUserMessageAt
|
||||||
|
? snapshot.lastUserMessageAt.toISOString()
|
||||||
|
: null,
|
||||||
|
status: snapshot.status,
|
||||||
|
sessionId: snapshot.sessionId,
|
||||||
|
capabilities: snapshot.capabilities,
|
||||||
|
currentModeId: snapshot.currentModeId,
|
||||||
|
availableModes: snapshot.availableModes,
|
||||||
|
pendingPermissions: snapshot.pendingPermissions,
|
||||||
|
persistence: sanitizePersistenceHandle(snapshot.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lastUsage = sanitizeOptionalJsonValue<AgentUsage>(snapshot.lastUsage);
|
||||||
|
if (lastUsage !== undefined) {
|
||||||
|
payload.lastUsage = lastUsage;
|
||||||
|
}
|
||||||
|
if (snapshot.lastError !== undefined) {
|
||||||
|
payload.lastError = snapshot.lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeAgentStreamEvent(
|
export function serializeAgentStreamEvent(
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ import {
|
|||||||
buildSessionConfig,
|
buildSessionConfig,
|
||||||
} from "./persistence-hooks.js";
|
} from "./persistence-hooks.js";
|
||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
||||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
|
||||||
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
|
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||||
@@ -66,6 +66,11 @@ import {
|
|||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
|
|
||||||
|
type AgentMcpClientConfig = {
|
||||||
|
agentMcpUrl: string;
|
||||||
|
agentMcpHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
|
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
|
||||||
let restartRequested = false;
|
let restartRequested = false;
|
||||||
@@ -223,6 +228,7 @@ export class Session {
|
|||||||
private agentTools: ToolSet | null = null;
|
private agentTools: ToolSet | null = null;
|
||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private readonly agentRegistry: AgentRegistry;
|
private readonly agentRegistry: AgentRegistry;
|
||||||
|
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||||
private agentTitleCache: Map<string, string | null> = new Map();
|
private agentTitleCache: Map<string, string | null> = new Map();
|
||||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -231,6 +237,7 @@ export class Session {
|
|||||||
onMessage: (msg: SessionOutboundMessage) => void,
|
onMessage: (msg: SessionOutboundMessage) => void,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentRegistry: AgentRegistry,
|
agentRegistry: AgentRegistry,
|
||||||
|
agentMcpConfig: AgentMcpClientConfig,
|
||||||
options?: {
|
options?: {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
initialMessages?: ModelMessage[];
|
initialMessages?: ModelMessage[];
|
||||||
@@ -241,6 +248,7 @@ export class Session {
|
|||||||
this.onMessage = onMessage;
|
this.onMessage = onMessage;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentRegistry = agentRegistry;
|
this.agentRegistry = agentRegistry;
|
||||||
|
this.agentMcpConfig = agentMcpConfig;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
// Initialize conversation history
|
// Initialize conversation history
|
||||||
@@ -434,18 +442,19 @@ export class Session {
|
|||||||
*/
|
*/
|
||||||
private async initializeAgentMcp(): Promise<void> {
|
private async initializeAgentMcp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const server = await createAgentMcpServer({
|
const transport = new StreamableHTTPClientTransport(
|
||||||
agentManager: this.agentManager,
|
new URL(this.agentMcpConfig.agentMcpUrl),
|
||||||
agentRegistry: this.agentRegistry,
|
this.agentMcpConfig.agentMcpHeaders
|
||||||
});
|
? {
|
||||||
|
requestInit: {
|
||||||
const [clientTransport, serverTransport] =
|
headers: this.agentMcpConfig.agentMcpHeaders,
|
||||||
InMemoryTransport.createLinkedPair();
|
},
|
||||||
|
}
|
||||||
await server.connect(serverTransport);
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
this.agentMcpClient = await experimental_createMCPClient({
|
this.agentMcpClient = await experimental_createMCPClient({
|
||||||
transport: clientTransport,
|
transport,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import { loadConversation } from "./persistence.js";
|
|||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||||
|
|
||||||
|
type AgentMcpClientConfig = {
|
||||||
|
agentMcpUrl: string;
|
||||||
|
agentMcpHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket server that routes messages between clients and their sessions.
|
* WebSocket server that routes messages between clients and their sessions.
|
||||||
* This is a thin transport layer with no business logic.
|
* This is a thin transport layer with no business logic.
|
||||||
@@ -24,14 +29,17 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private clientIdCounter: number = 0;
|
private clientIdCounter: number = 0;
|
||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private agentRegistry: AgentRegistry;
|
private agentRegistry: AgentRegistry;
|
||||||
|
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentRegistry: AgentRegistry
|
agentRegistry: AgentRegistry,
|
||||||
|
agentMcpConfig: AgentMcpClientConfig
|
||||||
) {
|
) {
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentRegistry = agentRegistry;
|
this.agentRegistry = agentRegistry;
|
||||||
|
this.agentMcpConfig = agentMcpConfig;
|
||||||
this.wss = new WebSocketServer({ server, path: "/ws" });
|
this.wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
this.wss.on("connection", (ws, request) => {
|
this.wss.on("connection", (ws, request) => {
|
||||||
@@ -79,6 +87,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
},
|
},
|
||||||
this.agentManager,
|
this.agentManager,
|
||||||
this.agentRegistry,
|
this.agentRegistry,
|
||||||
|
this.agentMcpConfig,
|
||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
initialMessages: initialMessages || undefined,
|
initialMessages: initialMessages || undefined,
|
||||||
|
|||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const CLAUDE_AGENT_ID = 'f7644b18-7fb9-4491-80f3-788d0d27a119';
|
||||||
|
const CODEX_AGENT_ID = 'd7c360ea-1579-4091-b224-ee8442400823';
|
||||||
|
|
||||||
|
const url = 'http://127.0.0.1:6767/mcp/agents';
|
||||||
|
const auth = Buffer.from('mo:bo').toString('base64');
|
||||||
|
|
||||||
|
async function makeRequest(method, params) {
|
||||||
|
console.log(`\n[TEST] Calling ${method} with params:`, JSON.stringify(params, null, 2));
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${auth}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: method,
|
||||||
|
arguments: params
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
console.log(`[TEST] Response received in ${elapsed}ms`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`[TEST] Response:`, JSON.stringify(data, null, 2));
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
console.error(`[TEST] Request failed after ${elapsed}ms:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
console.log('[TEST] Initializing MCP session...');
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${auth}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: {
|
||||||
|
name: 'test-client',
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[TEST] Initialized:', JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Testing MCP Agent Control Tools ===\n');
|
||||||
|
|
||||||
|
// Initialize session
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
// Test 1: Get status of Codex agent (should work)
|
||||||
|
console.log('\n--- Test 1: get_agent_status on Codex agent ---');
|
||||||
|
await makeRequest('get_agent_status', { agentId: CODEX_AGENT_ID });
|
||||||
|
|
||||||
|
// Test 2: Get status of Claude agent (might hang)
|
||||||
|
console.log('\n--- Test 2: get_agent_status on Claude agent ---');
|
||||||
|
await makeRequest('get_agent_status', { agentId: CLAUDE_AGENT_ID });
|
||||||
|
|
||||||
|
console.log('\n=== All tests complete ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
Reference in New Issue
Block a user