mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
feat(acp): expose session lineage metadata
Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
committed by
Peter Steinberger
parent
61e787724c
commit
df30515315
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
99
src/acp/session-lineage-meta.test.ts
Normal file
99
src/acp/session-lineage-meta.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
70
src/acp/session-lineage-meta.ts
Normal file
70
src/acp/session-lineage-meta.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
221
src/acp/translator.session-lineage-meta.test.ts
Normal file
221
src/acp/translator.session-lineage-meta.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user