From 3bd7615c4fed1fac7fa624e5799e2df2a3e8810b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 06:22:33 +0000 Subject: [PATCH] refactor: split acp mappers --- src/acp/event-mapper.test.ts | 34 +++++ src/acp/event-mapper.ts | 73 +++++++++ src/acp/index.ts | 2 + src/acp/meta.ts | 35 +++++ src/acp/session-mapper.test.ts | 57 +++++++ src/acp/session-mapper.ts | 95 ++++++++++++ src/acp/session.test.ts | 22 ++- src/acp/session.ts | 148 ++++++++++-------- src/acp/translator.ts | 246 +++++++----------------------- src/routing/session-key.ts | 48 ++---- src/sessions/session-key-utils.ts | 35 +++++ 11 files changed, 492 insertions(+), 303 deletions(-) create mode 100644 src/acp/event-mapper.test.ts create mode 100644 src/acp/event-mapper.ts create mode 100644 src/acp/meta.ts create mode 100644 src/acp/session-mapper.test.ts create mode 100644 src/acp/session-mapper.ts create mode 100644 src/sessions/session-key-utils.ts diff --git a/src/acp/event-mapper.test.ts b/src/acp/event-mapper.test.ts new file mode 100644 index 00000000000..c1e787d8e4d --- /dev/null +++ b/src/acp/event-mapper.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts new file mode 100644 index 00000000000..6e3e22f17b5 --- /dev/null +++ b/src/acp/event-mapper.ts @@ -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 | 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"; +} diff --git a/src/acp/index.ts b/src/acp/index.ts index feca02dbe56..6af9efffbe1 100644 --- a/src/acp/index.ts +++ b/src/acp/index.ts @@ -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"; diff --git a/src/acp/meta.ts b/src/acp/meta.ts new file mode 100644 index 00000000000..277b7491abe --- /dev/null +++ b/src/acp/meta.ts @@ -0,0 +1,35 @@ +export function readString( + meta: Record | 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 | 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 | 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; +} diff --git a/src/acp/session-mapper.test.ts b/src/acp/session-mapper.test.ts new file mode 100644 index 00000000000..d92315db933 --- /dev/null +++ b/src/acp/session-mapper.test.ts @@ -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; +} { + const request = vi.fn(async (method: string, params: Record) => { + 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(); + }); +}); diff --git a/src/acp/session-mapper.ts b/src/acp/session-mapper.ts new file mode 100644 index 00000000000..1a575a4139f --- /dev/null +++ b/src/acp/session-mapper.ts @@ -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; + 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 { + 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 { + const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false; + if (!resetSession) return; + await params.gateway.request("sessions.reset", { key: params.sessionKey }); +} diff --git a/src/acp/session.test.ts b/src/acp/session.test.ts index eb1c27120c3..619df3b5e9e 100644 --- a/src/acp/session.test.ts +++ b/src/acp/session.test.ts @@ -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(); }); }); diff --git a/src/acp/session.ts b/src/acp/session.ts index 51aff2bf99c..83ed88f4b25 100644 --- a/src/acp/session.ts +++ b/src/acp/session.ts @@ -2,70 +2,92 @@ import { randomUUID } from "node:crypto"; import type { AcpSession } from "./types.js"; -const sessions = new Map(); -const runIdToSessionId = new Map(); +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(); + const runIdToSessionId = new Map(); + + 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(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 7f596100767..14537be48af 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -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; }; -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(); 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 { - 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 { - 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 { - 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; - 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 { - 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 { - const resetSession = meta.resetSession ?? this.opts.resetSession ?? false; - if (!resetSession) return; - await this.gateway.request("sessions.reset", { key: sessionKey }); - } -} - -function readString( - meta: Record | 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 | 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 | 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 | 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"; } diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 6784385f493..687d63fdc8b 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -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; diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts new file mode 100644 index 00000000000..2bc90591d60 --- /dev/null +++ b/src/sessions/session-key-utils.ts @@ -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:")); +}