refactor: split acp mappers
This commit is contained in:
parent
41fbcc405f
commit
3bd7615c4f
34
src/acp/event-mapper.test.ts
Normal file
34
src/acp/event-mapper.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
|
||||
|
||||
describe("acp event mapper", () => {
|
||||
it("extracts text and resource blocks into prompt text", () => {
|
||||
const text = extractTextFromPrompt([
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "resource", resource: { text: "File contents" } },
|
||||
{ type: "resource_link", uri: "https://example.com", title: "Spec" },
|
||||
{ type: "image", data: "abc", mimeType: "image/png" },
|
||||
]);
|
||||
|
||||
expect(text).toBe(
|
||||
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts image blocks into gateway attachments", () => {
|
||||
const attachments = extractAttachmentsFromPrompt([
|
||||
{ type: "image", data: "abc", mimeType: "image/png" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
{ type: "text", text: "ignored" },
|
||||
]);
|
||||
|
||||
expect(attachments).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
content: "abc",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
73
src/acp/event-mapper.ts
Normal file
73
src/acp/event-mapper.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
|
||||
|
||||
export type GatewayAttachment = {
|
||||
type: string;
|
||||
mimeType: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function extractTextFromPrompt(prompt: ContentBlock[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text") {
|
||||
parts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "resource") {
|
||||
const resource = block.resource as { text?: string } | undefined;
|
||||
if (resource?.text) parts.push(resource.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
const title = block.title ? ` (${block.title})` : "";
|
||||
const uri = block.uri ?? "";
|
||||
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
|
||||
parts.push(line);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
|
||||
const attachments: GatewayAttachment[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type !== "image") continue;
|
||||
const image = block as ImageContent;
|
||||
if (!image.data || !image.mimeType) continue;
|
||||
attachments.push({
|
||||
type: "image",
|
||||
mimeType: image.mimeType,
|
||||
content: image.data,
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export function formatToolTitle(
|
||||
name: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
const base = name ?? "tool";
|
||||
if (!args || Object.keys(args).length === 0) return base;
|
||||
const parts = Object.entries(args).map(([key, value]) => {
|
||||
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
|
||||
return `${key}: ${safe}`;
|
||||
});
|
||||
return `${base}: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
export function inferToolKind(name?: string): ToolKind | undefined {
|
||||
if (!name) return "other";
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized.includes("read")) return "read";
|
||||
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
|
||||
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
|
||||
if (normalized.includes("move") || normalized.includes("rename")) return "move";
|
||||
if (normalized.includes("search") || normalized.includes("find")) return "search";
|
||||
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
|
||||
return "execute";
|
||||
}
|
||||
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
|
||||
return "other";
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export { serveAcpGateway } from "./server.js";
|
||||
export { createInMemorySessionStore } from "./session.js";
|
||||
export type { AcpSessionStore } from "./session.js";
|
||||
export type { AcpServerOptions } from "./types.js";
|
||||
|
||||
35
src/acp/meta.ts
Normal file
35
src/acp/meta.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export function readString(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readBool(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): boolean | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readNumber(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
57
src/acp/session-mapper.test.ts
Normal file
57
src/acp/session-mapper.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
|
||||
|
||||
function createGateway(resolveLabelKey = "agent:main:label"): {
|
||||
gateway: GatewayClient;
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.resolve" && "label" in params) {
|
||||
return { ok: true, key: resolveLabelKey };
|
||||
}
|
||||
if (method === "sessions.resolve" && "key" in params) {
|
||||
return { ok: true, key: params.key as string };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
return {
|
||||
gateway: { request } as unknown as GatewayClient,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
describe("acp session mapper", () => {
|
||||
it("prefers explicit sessionLabel over sessionKey", async () => {
|
||||
const { gateway, request } = createGateway();
|
||||
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
|
||||
|
||||
const key = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: "acp:fallback",
|
||||
gateway,
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(key).toBe("agent:main:label");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
|
||||
});
|
||||
|
||||
it("lets meta sessionKey override default label", async () => {
|
||||
const { gateway, request } = createGateway();
|
||||
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
|
||||
|
||||
const key = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: "acp:fallback",
|
||||
gateway,
|
||||
opts: { defaultSessionLabel: "default-label" },
|
||||
});
|
||||
|
||||
expect(key).toBe("agent:main:override");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
95
src/acp/session-mapper.ts
Normal file
95
src/acp/session-mapper.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
|
||||
import type { AcpServerOptions } from "./types.js";
|
||||
import { readBool, readString } from "./meta.js";
|
||||
|
||||
export type AcpSessionMeta = {
|
||||
sessionKey?: string;
|
||||
sessionLabel?: string;
|
||||
resetSession?: boolean;
|
||||
requireExisting?: boolean;
|
||||
prefixCwd?: boolean;
|
||||
};
|
||||
|
||||
export function parseSessionMeta(meta: unknown): AcpSessionMeta {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const record = meta as Record<string, unknown>;
|
||||
return {
|
||||
sessionKey: readString(record, ["sessionKey", "session", "key"]),
|
||||
sessionLabel: readString(record, ["sessionLabel", "label"]),
|
||||
resetSession: readBool(record, ["resetSession", "reset"]),
|
||||
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
|
||||
prefixCwd: readBool(record, ["prefixCwd"]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSessionKey(params: {
|
||||
meta: AcpSessionMeta;
|
||||
fallbackKey: string;
|
||||
gateway: GatewayClient;
|
||||
opts: AcpServerOptions;
|
||||
}): Promise<string> {
|
||||
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
|
||||
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
|
||||
const requireExisting =
|
||||
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
|
||||
|
||||
if (params.meta.sessionLabel) {
|
||||
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ label: params.meta.sessionLabel },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
if (params.meta.sessionKey) {
|
||||
if (!requireExisting) return params.meta.sessionKey;
|
||||
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ key: params.meta.sessionKey },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
if (requestedLabel) {
|
||||
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ label: requestedLabel },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
if (requestedKey) {
|
||||
if (!requireExisting) return requestedKey;
|
||||
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ key: requestedKey },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Session key not found: ${requestedKey}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
return params.fallbackKey;
|
||||
}
|
||||
|
||||
export async function resetSessionIfNeeded(params: {
|
||||
meta: AcpSessionMeta;
|
||||
sessionKey: string;
|
||||
gateway: GatewayClient;
|
||||
opts: AcpServerOptions;
|
||||
}): Promise<void> {
|
||||
const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
|
||||
if (!resetSession) return;
|
||||
await params.gateway.request("sessions.reset", { key: params.sessionKey });
|
||||
}
|
||||
@ -1,30 +1,26 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
cancelActiveRun,
|
||||
clearAllSessionsForTest,
|
||||
createSession,
|
||||
getSessionByRunId,
|
||||
setActiveRun,
|
||||
} from "./session.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
|
||||
describe("acp session manager", () => {
|
||||
const store = createInMemorySessionStore();
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionsForTest();
|
||||
store.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("tracks active runs and clears on cancel", () => {
|
||||
const session = createSession({
|
||||
const session = store.createSession({
|
||||
sessionKey: "acp:test",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setActiveRun(session.sessionId, "run-1", controller);
|
||||
store.setActiveRun(session.sessionId, "run-1", controller);
|
||||
|
||||
expect(getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||
|
||||
const cancelled = cancelActiveRun(session.sessionId);
|
||||
const cancelled = store.cancelActiveRun(session.sessionId);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(getSessionByRunId("run-1")).toBeUndefined();
|
||||
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,70 +2,92 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { AcpSession } from "./types.js";
|
||||
|
||||
const sessions = new Map<string, AcpSession>();
|
||||
const runIdToSessionId = new Map<string, string>();
|
||||
export type AcpSessionStore = {
|
||||
createSession: (params: {
|
||||
sessionKey: string;
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
}) => AcpSession;
|
||||
getSession: (sessionId: string) => AcpSession | undefined;
|
||||
getSessionByRunId: (runId: string) => AcpSession | undefined;
|
||||
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
|
||||
clearActiveRun: (sessionId: string) => void;
|
||||
cancelActiveRun: (sessionId: string) => boolean;
|
||||
clearAllSessionsForTest: () => void;
|
||||
};
|
||||
|
||||
export function createSession(params: {
|
||||
sessionKey: string;
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
}): AcpSession {
|
||||
const sessionId = params.sessionId ?? randomUUID();
|
||||
const session: AcpSession = {
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
cwd: params.cwd,
|
||||
createdAt: Date.now(),
|
||||
abortController: null,
|
||||
activeRunId: null,
|
||||
export function createInMemorySessionStore(): AcpSessionStore {
|
||||
const sessions = new Map<string, AcpSession>();
|
||||
const runIdToSessionId = new Map<string, string>();
|
||||
|
||||
const createSession: AcpSessionStore["createSession"] = (params) => {
|
||||
const sessionId = params.sessionId ?? randomUUID();
|
||||
const session: AcpSession = {
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
cwd: params.cwd,
|
||||
createdAt: Date.now(),
|
||||
abortController: null,
|
||||
activeRunId: null,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
return session;
|
||||
};
|
||||
|
||||
const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
|
||||
|
||||
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
|
||||
const sessionId = runIdToSessionId.get(runId);
|
||||
return sessionId ? sessions.get(sessionId) : undefined;
|
||||
};
|
||||
|
||||
const setActiveRun: AcpSessionStore["setActiveRun"] = (
|
||||
sessionId,
|
||||
runId,
|
||||
abortController,
|
||||
) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.activeRunId = runId;
|
||||
session.abortController = abortController;
|
||||
runIdToSessionId.set(runId, sessionId);
|
||||
};
|
||||
|
||||
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.activeRunId = null;
|
||||
session.abortController = null;
|
||||
};
|
||||
|
||||
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.abortController) return false;
|
||||
session.abortController.abort();
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.abortController = null;
|
||||
session.activeRunId = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
|
||||
for (const session of sessions.values()) {
|
||||
session.abortController?.abort();
|
||||
}
|
||||
sessions.clear();
|
||||
runIdToSessionId.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
createSession,
|
||||
getSession,
|
||||
getSessionByRunId,
|
||||
setActiveRun,
|
||||
clearActiveRun,
|
||||
cancelActiveRun,
|
||||
clearAllSessionsForTest,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): AcpSession | undefined {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
export function getSessionByRunId(runId: string): AcpSession | undefined {
|
||||
const sessionId = runIdToSessionId.get(runId);
|
||||
return sessionId ? sessions.get(sessionId) : undefined;
|
||||
}
|
||||
|
||||
export function setActiveRun(
|
||||
sessionId: string,
|
||||
runId: string,
|
||||
abortController: AbortController,
|
||||
): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.activeRunId = runId;
|
||||
session.abortController = abortController;
|
||||
runIdToSessionId.set(runId, sessionId);
|
||||
}
|
||||
|
||||
export function clearActiveRun(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.activeRunId = null;
|
||||
session.abortController = null;
|
||||
}
|
||||
|
||||
export function cancelActiveRun(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.abortController) return false;
|
||||
session.abortController.abort();
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.abortController = null;
|
||||
session.activeRunId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearAllSessionsForTest(): void {
|
||||
for (const session of sessions.values()) {
|
||||
session.abortController?.abort();
|
||||
}
|
||||
sessions.clear();
|
||||
runIdToSessionId.clear();
|
||||
}
|
||||
export const defaultAcpSessionStore = createInMemorySessionStore();
|
||||
|
||||
@ -6,8 +6,6 @@ import type {
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
CancelNotification,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ListSessionsRequest,
|
||||
@ -21,21 +19,22 @@ import type {
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import type { SessionsListResult } from "../gateway/session-utils.js";
|
||||
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
|
||||
import { readBool, readNumber, readString } from "./meta.js";
|
||||
import {
|
||||
cancelActiveRun,
|
||||
clearActiveRun,
|
||||
createSession,
|
||||
getSession,
|
||||
setActiveRun,
|
||||
} from "./session.js";
|
||||
extractAttachmentsFromPrompt,
|
||||
extractTextFromPrompt,
|
||||
formatToolTitle,
|
||||
inferToolKind,
|
||||
} from "./event-mapper.js";
|
||||
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
|
||||
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
|
||||
import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js";
|
||||
|
||||
type PendingPrompt = {
|
||||
sessionId: string;
|
||||
@ -48,25 +47,22 @@ type PendingPrompt = {
|
||||
toolCalls?: Set<string>;
|
||||
};
|
||||
|
||||
type SessionMeta = {
|
||||
sessionKey?: string;
|
||||
sessionLabel?: string;
|
||||
resetSession?: boolean;
|
||||
requireExisting?: boolean;
|
||||
prefixCwd?: boolean;
|
||||
type AcpGatewayAgentOptions = AcpServerOptions & {
|
||||
sessionStore?: AcpSessionStore;
|
||||
};
|
||||
|
||||
export class AcpGatewayAgent implements Agent {
|
||||
private connection: AgentSideConnection;
|
||||
private gateway: GatewayClient;
|
||||
private opts: AcpServerOptions;
|
||||
private opts: AcpGatewayAgentOptions;
|
||||
private log: (msg: string) => void;
|
||||
private sessionStore: AcpSessionStore;
|
||||
private pendingPrompts = new Map<string, PendingPrompt>();
|
||||
|
||||
constructor(
|
||||
connection: AgentSideConnection,
|
||||
gateway: GatewayClient,
|
||||
opts: AcpServerOptions = {},
|
||||
opts: AcpGatewayAgentOptions = {},
|
||||
) {
|
||||
this.connection = connection;
|
||||
this.gateway = gateway;
|
||||
@ -74,6 +70,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
this.log = opts.verbose
|
||||
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
|
||||
: () => {};
|
||||
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@ -88,7 +85,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
this.log(`gateway disconnected: ${reason}`);
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
pending.reject(new Error(`Gateway disconnected: ${reason}`));
|
||||
clearActiveRun(pending.sessionId);
|
||||
this.sessionStore.clearActiveRun(pending.sessionId);
|
||||
}
|
||||
this.pendingPrompts.clear();
|
||||
}
|
||||
@ -132,11 +129,21 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKey(meta, `acp:${sessionId}`);
|
||||
await this.resetSessionIfNeeded(meta, sessionKey);
|
||||
const meta = parseSessionMeta(params._meta);
|
||||
const sessionKey = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: `acp:${sessionId}`,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
await resetSessionIfNeeded({
|
||||
meta,
|
||||
sessionKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
|
||||
const session = createSession({
|
||||
const session = this.sessionStore.createSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
cwd: params.cwd,
|
||||
@ -150,11 +157,21 @@ export class AcpGatewayAgent implements Agent {
|
||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
||||
}
|
||||
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKey(meta, params.sessionId);
|
||||
await this.resetSessionIfNeeded(meta, sessionKey);
|
||||
const meta = parseSessionMeta(params._meta);
|
||||
const sessionKey = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: params.sessionId,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
await resetSessionIfNeeded({
|
||||
meta,
|
||||
sessionKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
|
||||
const session = createSession({
|
||||
const session = this.sessionStore.createSession({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey,
|
||||
cwd: params.cwd,
|
||||
@ -190,7 +207,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const session = getSession(params.sessionId);
|
||||
const session = this.sessionStore.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`);
|
||||
}
|
||||
@ -208,22 +225,22 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = getSession(params.sessionId);
|
||||
const session = this.sessionStore.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.abortController) {
|
||||
cancelActiveRun(params.sessionId);
|
||||
this.sessionStore.cancelActiveRun(params.sessionId);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runId = randomUUID();
|
||||
setActiveRun(params.sessionId, runId, abortController);
|
||||
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
|
||||
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const userText = this.extractTextFromPrompt(params.prompt);
|
||||
const attachments = this.extractAttachmentsFromPrompt(params.prompt);
|
||||
const meta = parseSessionMeta(params._meta);
|
||||
const userText = extractTextFromPrompt(params.prompt);
|
||||
const attachments = extractAttachmentsFromPrompt(params.prompt);
|
||||
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
|
||||
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
|
||||
|
||||
@ -252,17 +269,17 @@ export class AcpGatewayAgent implements Agent {
|
||||
)
|
||||
.catch((err) => {
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
clearActiveRun(params.sessionId);
|
||||
this.sessionStore.clearActiveRun(params.sessionId);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = getSession(params.sessionId);
|
||||
const session = this.sessionStore.getSession(params.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
cancelActiveRun(params.sessionId);
|
||||
this.sessionStore.cancelActiveRun(params.sessionId);
|
||||
try {
|
||||
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
|
||||
} catch (err) {
|
||||
@ -389,7 +406,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
stopReason: StopReason,
|
||||
): void {
|
||||
this.pendingPrompts.delete(sessionId);
|
||||
clearActiveRun(sessionId);
|
||||
this.sessionStore.clearActiveRun(sessionId);
|
||||
pending.resolve({ stopReason });
|
||||
}
|
||||
|
||||
@ -400,157 +417,4 @@ export class AcpGatewayAgent implements Agent {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractTextFromPrompt(prompt: ContentBlock[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text") {
|
||||
parts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "resource") {
|
||||
const resource = block.resource as { text?: string } | undefined;
|
||||
if (resource?.text) parts.push(resource.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
const title = block.title ? ` (${block.title})` : "";
|
||||
const uri = block.uri ?? "";
|
||||
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
|
||||
parts.push(line);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
private extractAttachmentsFromPrompt(
|
||||
prompt: ContentBlock[],
|
||||
): Array<{ type: string; mimeType: string; content: string }> {
|
||||
const attachments: Array<{ type: string; mimeType: string; content: string }> = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type !== "image") continue;
|
||||
const image = block as ImageContent;
|
||||
if (!image.data || !image.mimeType) continue;
|
||||
attachments.push({
|
||||
type: "image",
|
||||
mimeType: image.mimeType,
|
||||
content: image.data,
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private parseSessionMeta(meta: unknown): SessionMeta {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const record = meta as Record<string, unknown>;
|
||||
return {
|
||||
sessionKey: readString(record, ["sessionKey", "session", "key"]),
|
||||
sessionLabel: readString(record, ["sessionLabel", "label"]),
|
||||
resetSession: readBool(record, ["resetSession", "reset"]),
|
||||
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
|
||||
prefixCwd: readBool(record, ["prefixCwd"]),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveSessionKey(meta: SessionMeta, fallbackKey: string): Promise<string> {
|
||||
const requestedKey = meta.sessionKey ?? this.opts.defaultSessionKey;
|
||||
const requestedLabel = meta.sessionLabel ?? this.opts.defaultSessionLabel;
|
||||
const requireExisting =
|
||||
meta.requireExisting ?? this.opts.requireExistingSession ?? false;
|
||||
|
||||
if (requestedLabel) {
|
||||
const resolved = await this.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ label: requestedLabel },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
if (requestedKey) {
|
||||
if (!requireExisting) return requestedKey;
|
||||
const resolved = await this.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ key: requestedKey },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Session key not found: ${requestedKey}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
private async resetSessionIfNeeded(meta: SessionMeta, sessionKey: string): Promise<void> {
|
||||
const resetSession = meta.resetSession ?? this.opts.resetSession ?? false;
|
||||
if (!resetSession) return;
|
||||
await this.gateway.request("sessions.reset", { key: sessionKey });
|
||||
}
|
||||
}
|
||||
|
||||
function readString(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readBool(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): boolean | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumber(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
if (!meta) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = meta[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatToolTitle(
|
||||
name: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
const base = name ?? "tool";
|
||||
if (!args || Object.keys(args).length === 0) return base;
|
||||
const parts = Object.entries(args).map(([key, value]) => {
|
||||
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
|
||||
return `${key}: ${safe}`;
|
||||
});
|
||||
return `${base}: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function inferToolKind(name?: string): ToolKind | undefined {
|
||||
if (!name) return "other";
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized.includes("read")) return "read";
|
||||
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
|
||||
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
|
||||
if (normalized.includes("move") || normalized.includes("rename")) return "move";
|
||||
if (normalized.includes("search") || normalized.includes("find")) return "search";
|
||||
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
|
||||
return "execute";
|
||||
}
|
||||
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@ -1,3 +1,15 @@
|
||||
import {
|
||||
parseAgentSessionKey,
|
||||
type ParsedAgentSessionKey,
|
||||
} from "../sessions/session-key-utils.js";
|
||||
|
||||
export {
|
||||
isAcpSessionKey,
|
||||
isSubagentSessionKey,
|
||||
parseAgentSessionKey,
|
||||
type ParsedAgentSessionKey,
|
||||
} from "../sessions/session-key-utils.js";
|
||||
|
||||
export const DEFAULT_AGENT_ID = "main";
|
||||
export const DEFAULT_MAIN_KEY = "main";
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
@ -11,11 +23,6 @@ export function normalizeMainKey(value: string | undefined | null): string {
|
||||
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
|
||||
}
|
||||
|
||||
export type ParsedAgentSessionKey = {
|
||||
agentId: string;
|
||||
rest: string;
|
||||
};
|
||||
|
||||
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
|
||||
const raw = (storeKey ?? "").trim();
|
||||
if (!raw) return undefined;
|
||||
@ -70,37 +77,6 @@ export function normalizeAccountId(value: string | undefined | null): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAgentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): ParsedAgentSessionKey | null {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
if (parts[0] !== "agent") return null;
|
||||
const agentId = parts[1]?.trim();
|
||||
const rest = parts.slice(2).join(":");
|
||||
if (!agentId || !rest) return null;
|
||||
return { agentId, rest };
|
||||
}
|
||||
|
||||
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
if (raw.toLowerCase().startsWith("subagent:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
||||
}
|
||||
|
||||
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized.startsWith("acp:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
||||
}
|
||||
|
||||
export function buildAgentMainSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
|
||||
35
src/sessions/session-key-utils.ts
Normal file
35
src/sessions/session-key-utils.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export type ParsedAgentSessionKey = {
|
||||
agentId: string;
|
||||
rest: string;
|
||||
};
|
||||
|
||||
export function parseAgentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): ParsedAgentSessionKey | null {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
if (parts[0] !== "agent") return null;
|
||||
const agentId = parts[1]?.trim();
|
||||
const rest = parts.slice(2).join(":");
|
||||
if (!agentId || !rest) return null;
|
||||
return { agentId, rest };
|
||||
}
|
||||
|
||||
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
if (raw.toLowerCase().startsWith("subagent:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
||||
}
|
||||
|
||||
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized.startsWith("acp:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user