feat(acp): expose session lineage metadata

Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
samzong
2026-04-28 17:36:53 +08:00
committed by Peter Steinberger
parent 61e787724c
commit df30515315
8 changed files with 436 additions and 6 deletions

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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",
});
});
});

View File

@@ -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<T extends string>(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 } : {}),
};
}

View File

@@ -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",
},
},
});

View File

@@ -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<string, unknown> = {},
): 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();
});
});

View File

@@ -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({

View File

@@ -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,