diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3140116ef..32a98b1d910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC. +- ACP: expose Gateway session lineage metadata through ACP session listings and session info snapshots so clients can render subagent graphs without private Gateway side channels. (#73458) Thanks @samzong. - CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate. - Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps. - Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 70044eaa411..13a8090c462 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -43,6 +43,7 @@ Quick rule: | --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | | `listSessions`, slash commands | Implemented | Session list works against Gateway session state with bounded cursor pagination and `cwd` filtering where Gateway session rows carry workspace metadata; commands are advertised via `available_commands_update`. | +| Session lineage metadata | Implemented | Session listings and session info snapshots include OpenClaw parent and child lineage in `_meta` so ACP clients can render subagent graphs without private Gateway side channels. | | `resumeSession`, `closeSession` | Implemented | Resume rebinds an ACP session to an existing Gateway session without replaying history. Close cancels active bridge work, resolves pending prompts as cancelled, and releases bridge session state. | | `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays ACP event-ledger history for bridge-created sessions. Older/no-ledger sessions fall back to stored user/assistant text. | | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | diff --git a/src/acp/session-lineage-meta.test.ts b/src/acp/session-lineage-meta.test.ts new file mode 100644 index 00000000000..b290d4c94d5 --- /dev/null +++ b/src/acp/session-lineage-meta.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { toAcpSessionLineageMeta, type AcpSessionLineageRow } from "./session-lineage-meta.js"; + +describe("toAcpSessionLineageMeta", () => { + it("keeps root session metadata minimal", () => { + const meta = toAcpSessionLineageMeta({ + key: "agent:main:main", + kind: "direct", + channel: "telegram", + }); + + expect(meta).toEqual({ + sessionKey: "agent:main:main", + kind: "direct", + channel: "telegram", + }); + expect(Object.keys(meta)).toEqual(["sessionKey", "kind", "channel"]); + }); + + it("maps a one-level child parent key into parentSessionId", () => { + const meta = toAcpSessionLineageMeta({ + key: "agent:main:subagent:child", + kind: "direct", + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "orchestrator", + subagentControlScope: "children", + }); + + expect(meta).toEqual({ + sessionKey: "agent:main:subagent:child", + kind: "direct", + parentSessionId: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "orchestrator", + subagentControlScope: "children", + }); + }); + + it("keeps multi-level child lineage and workspace metadata", () => { + const meta = toAcpSessionLineageMeta({ + key: "agent:main:subagent:parent:subagent:leaf", + kind: "direct", + parentSessionKey: "agent:main:subagent:parent", + spawnedBy: "agent:main:subagent:parent", + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + spawnedWorkspaceDir: "/workspace/leaf", + }); + + expect(meta).toEqual({ + sessionKey: "agent:main:subagent:parent:subagent:leaf", + kind: "direct", + parentSessionId: "agent:main:subagent:parent", + spawnedBy: "agent:main:subagent:parent", + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + spawnedWorkspaceDir: "/workspace/leaf", + }); + }); + + it("falls back to spawnedBy for parentSessionId when no explicit parent key is present", () => { + expect( + toAcpSessionLineageMeta({ + key: "agent:main:subagent:child", + kind: "direct", + spawnedBy: "agent:main:main", + }), + ).toEqual({ + sessionKey: "agent:main:subagent:child", + kind: "direct", + parentSessionId: "agent:main:main", + spawnedBy: "agent:main:main", + }); + }); + + it("omits malformed optional lineage values", () => { + const row = { + key: "agent:main:subagent:broken", + kind: "direct", + channel: "", + parentSessionKey: " ", + spawnedBy: 42, + spawnDepth: 1.5, + subagentRole: "worker", + subagentControlScope: "all", + spawnedWorkspaceDir: "", + } as unknown as AcpSessionLineageRow; + + expect(toAcpSessionLineageMeta(row)).toEqual({ + sessionKey: "agent:main:subagent:broken", + kind: "direct", + }); + }); +}); diff --git a/src/acp/session-lineage-meta.ts b/src/acp/session-lineage-meta.ts new file mode 100644 index 00000000000..39f7de952a5 --- /dev/null +++ b/src/acp/session-lineage-meta.ts @@ -0,0 +1,70 @@ +import type { GatewaySessionRow } from "../gateway/session-utils.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +const SUBAGENT_ROLES = ["orchestrator", "leaf"] as const; +const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const; + +type SubagentRole = (typeof SUBAGENT_ROLES)[number]; +type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number]; + +export type AcpSessionLineageMeta = { + sessionKey: string; + kind?: string; + channel?: string; + parentSessionId?: string; + spawnedBy?: string; + spawnDepth?: number; + subagentRole?: SubagentRole; + subagentControlScope?: SubagentControlScope; + spawnedWorkspaceDir?: string; +}; + +export type AcpSessionLineageRow = Pick< + GatewaySessionRow, + | "key" + | "kind" + | "channel" + | "parentSessionKey" + | "spawnedBy" + | "spawnDepth" + | "subagentRole" + | "subagentControlScope" + | "spawnedWorkspaceDir" +>; + +function readInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + return undefined; + } + return value; +} + +function readEnum(value: unknown, allowed: readonly T[]): T | undefined { + const normalized = normalizeOptionalString(value); + return allowed.find((candidate) => candidate === normalized); +} + +export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLineageMeta { + const sessionKey = normalizeOptionalString(row.key) ?? row.key; + const kind = normalizeOptionalString(row.kind); + const channel = normalizeOptionalString(row.channel); + const parentSessionId = + normalizeOptionalString(row.parentSessionKey) ?? normalizeOptionalString(row.spawnedBy); + const spawnedBy = normalizeOptionalString(row.spawnedBy); + const spawnDepth = readInteger(row.spawnDepth); + const subagentRole = readEnum(row.subagentRole, SUBAGENT_ROLES); + const subagentControlScope = readEnum(row.subagentControlScope, SUBAGENT_CONTROL_SCOPES); + const spawnedWorkspaceDir = normalizeOptionalString(row.spawnedWorkspaceDir); + + return { + sessionKey, + ...(kind ? { kind } : {}), + ...(channel ? { channel } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(spawnedBy ? { spawnedBy } : {}), + ...(spawnDepth !== undefined ? { spawnDepth } : {}), + ...(subagentRole ? { subagentRole } : {}), + ...(subagentControlScope ? { subagentControlScope } : {}), + ...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}), + }; +} diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 6c693fee702..5bf5c9f5210 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -351,6 +351,11 @@ describe("acp translator stable lifecycle handlers", () => { sessionUpdate: "session_info_update", title: "Work session", updatedAt: "2024-03-09T16:00:00.000Z", + _meta: { + sessionKey: "agent:main:work", + kind: "direct", + spawnedWorkspaceDir: "/tmp/openclaw", + }, }, }); diff --git a/src/acp/translator.session-lineage-meta.test.ts b/src/acp/translator.session-lineage-meta.test.ts new file mode 100644 index 00000000000..82446e6a1ff --- /dev/null +++ b/src/acp/translator.session-lineage-meta.test.ts @@ -0,0 +1,221 @@ +import type { ListSessionsRequest, LoadSessionRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +vi.mock("./commands.js", () => ({ + getAvailableCommands: () => [], +})); + +function createLoadSessionRequest( + sessionId: string, + meta: Record = {}, +): LoadSessionRequest { + return { + sessionId, + cwd: "/workspace", + mcpServers: [], + _meta: meta, + } as unknown as LoadSessionRequest; +} + +describe("acp session lineage metadata", () => { + it("includes lineage metadata in listSessions results", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: 1, + path: "/tmp/sessions.json", + count: 2, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + channel: "telegram", + displayName: "Main", + updatedAt: 1_710_000_000_000, + }, + { + key: "agent:main:subagent:child", + kind: "direct", + channel: "telegram", + displayName: "Child", + updatedAt: 1_710_000_010_000, + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "orchestrator", + subagentControlScope: "children", + spawnedWorkspaceDir: "/workspace/child", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { + sessionStore: createInMemorySessionStore(), + }); + + const result = await agent.listSessions({ + _meta: {}, + } as unknown as ListSessionsRequest); + + expect(result.sessions[0]?._meta).toEqual({ + sessionKey: "agent:main:main", + kind: "direct", + channel: "telegram", + }); + expect(result.sessions[1]?._meta).toEqual({ + sessionKey: "agent:main:subagent:child", + kind: "direct", + channel: "telegram", + parentSessionId: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "orchestrator", + subagentControlScope: "children", + spawnedWorkspaceDir: "/workspace/child", + }); + }); + + it("includes lineage metadata in initial session snapshot updates", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: 1, + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:subagent:child", + kind: "direct", + channel: "discord", + displayName: "Child", + updatedAt: 1_710_000_020_000, + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + spawnedWorkspaceDir: "/workspace/child", + }, + ], + }; + } + if (method === "sessions.get") { + return { messages: [] }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("agent:main:subagent:child")); + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:subagent:child", + update: { + sessionUpdate: "session_info_update", + title: "Child", + updatedAt: "2024-03-09T16:00:20.000Z", + _meta: { + sessionKey: "agent:main:subagent:child", + kind: "direct", + channel: "discord", + parentSessionId: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + spawnedWorkspaceDir: "/workspace/child", + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); + + it("keeps snapshot lineage in the Gateway session key namespace", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const gatewaySessionKey = "agent:main:subagent:child"; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: 1, + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: gatewaySessionKey, + kind: "direct", + displayName: "Child", + updatedAt: 1_710_000_020_000, + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + }, + ], + }; + } + if (method === "sessions.get") { + return { messages: [] }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession( + createLoadSessionRequest("client-local-session", { + sessionKey: gatewaySessionKey, + }), + ); + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "client-local-session", + update: { + sessionUpdate: "session_info_update", + title: "Child", + updatedAt: "2024-03-09T16:00:20.000Z", + _meta: { + sessionKey: gatewaySessionKey, + kind: "direct", + parentSessionId: "agent:main:main", + spawnedBy: "agent:main:main", + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 35cca2a83c1..36870bb19ff 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -379,6 +379,10 @@ describe("acp session UX bridge behavior", () => { sessionUpdate: "session_info_update", title: "Fix ACP bridge", updatedAt: "2024-03-09T16:00:00.000Z", + _meta: { + sessionKey: "agent:main:work", + kind: "direct", + }, }, }); expect(sessionUpdate).toHaveBeenCalledWith({ @@ -952,6 +956,10 @@ describe("acp session metadata and usage updates", () => { sessionUpdate: "session_info_update", title: "Usage session", updatedAt: "2024-03-09T16:02:03.000Z", + _meta: { + sessionKey: "usage-session", + kind: "direct", + }, }, }); expect(sessionUpdate).toHaveBeenCalledWith({ diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 8d84cdb7730..4ff3441c997 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -66,6 +66,7 @@ import { type GatewayExecApprovalDetails, type GatewayExecApprovalEvent, } from "./permission-relay.js"; +import { toAcpSessionLineageMeta, type AcpSessionLineageMeta } from "./session-lineage-meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js"; import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; @@ -153,6 +154,15 @@ type AcpGatewayAgentOptions = AcpServerOptions & { type GatewaySessionPresentationRow = Pick< GatewaySessionRow, + | "key" + | "kind" + | "channel" + | "parentSessionKey" + | "spawnedBy" + | "spawnDepth" + | "subagentRole" + | "subagentControlScope" + | "spawnedWorkspaceDir" | "displayName" | "label" | "derivedTitle" @@ -180,6 +190,7 @@ type SessionPresentation = { type SessionMetadata = { title?: string | null; updatedAt?: string | null; + _meta?: AcpSessionLineageMeta; }; type SessionUsageSnapshot = { @@ -493,7 +504,16 @@ function buildSessionMetadata(params: { typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt) ? new Date(params.row.updatedAt).toISOString() : null; - return { title, updatedAt }; + return { + title, + updatedAt, + _meta: toAcpSessionLineageMeta( + params.row ?? { + key: params.sessionKey, + kind: "unknown", + }, + ), + }; } function buildSessionUsageSnapshot( @@ -1885,11 +1905,7 @@ export class AcpGatewayAgent implements Agent { cwd, title: session.derivedTitle ?? session.displayName ?? session.label ?? session.key, updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined, - _meta: { - sessionKey: session.key, - kind: session.kind, - channel: session.channel, - }, + _meta: toAcpSessionLineageMeta(session), }; } @@ -1941,6 +1957,15 @@ export class AcpGatewayAgent implements Agent { return undefined; } return { + key: session.key, + kind: session.kind, + channel: session.channel, + parentSessionKey: session.parentSessionKey, + spawnedBy: session.spawnedBy, + spawnDepth: session.spawnDepth, + subagentRole: session.subagentRole, + subagentControlScope: session.subagentControlScope, + spawnedWorkspaceDir: session.spawnedWorkspaceDir, displayName: session.displayName, label: session.label, derivedTitle: session.derivedTitle,