Skip to content

Commit 385c5c5

Browse files
committed
feat(gateway): add artifact RPCs
1 parent eab4024 commit 385c5c5

10 files changed

Lines changed: 684 additions & 0 deletions

File tree

src/gateway/method-scopes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
121121
"talk.config",
122122
"agents.files.list",
123123
"agents.files.get",
124+
"artifacts.list",
125+
"artifacts.get",
126+
"artifacts.download",
124127
],
125128
[WRITE_SCOPE]: [
126129
"message.action",

src/gateway/protocol/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ import {
3838
AgentsFilesSetParamsSchema,
3939
type AgentsFilesSetResult,
4040
AgentsFilesSetResultSchema,
41+
type ArtifactsDownloadParams,
42+
ArtifactsDownloadParamsSchema,
43+
type ArtifactsDownloadResult,
44+
type ArtifactsGetParams,
45+
ArtifactsGetParamsSchema,
46+
type ArtifactsGetResult,
47+
type ArtifactsListParams,
48+
ArtifactsListParamsSchema,
49+
type ArtifactsListResult,
50+
type ArtifactSummary,
51+
ArtifactSummarySchema,
4152
type AgentsListParams,
4253
AgentsListParamsSchema,
4354
type AgentsListResult,
@@ -367,6 +378,12 @@ export const validateAgentsFilesGetParams = ajv.compile<AgentsFilesGetParams>(
367378
export const validateAgentsFilesSetParams = ajv.compile<AgentsFilesSetParams>(
368379
AgentsFilesSetParamsSchema,
369380
);
381+
export const validateArtifactsListParams =
382+
ajv.compile<ArtifactsListParams>(ArtifactsListParamsSchema);
383+
export const validateArtifactsGetParams = ajv.compile<ArtifactsGetParams>(ArtifactsGetParamsSchema);
384+
export const validateArtifactsDownloadParams = ajv.compile<ArtifactsDownloadParams>(
385+
ArtifactsDownloadParamsSchema,
386+
);
370387
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
371388
NodePairRequestParamsSchema,
372389
);
@@ -845,6 +862,13 @@ export type {
845862
AgentsFilesGetResult,
846863
AgentsFilesSetParams,
847864
AgentsFilesSetResult,
865+
ArtifactSummary,
866+
ArtifactsListParams,
867+
ArtifactsListResult,
868+
ArtifactsGetParams,
869+
ArtifactsGetResult,
870+
ArtifactsDownloadParams,
871+
ArtifactsDownloadResult,
848872
AgentsListParams,
849873
AgentsListResult,
850874
CommandsListParams,

src/gateway/protocol/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./schema/agent.js";
22
export * from "./schema/agents-models-skills.js";
3+
export * from "./schema/artifacts.js";
34
export * from "./schema/channels.js";
45
export * from "./schema/commands.js";
56
export * from "./schema/config.js";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Type } from "typebox";
2+
import { NonEmptyString } from "./primitives.js";
3+
4+
export const ArtifactQueryParamsSchema = Type.Object(
5+
{
6+
sessionKey: Type.Optional(NonEmptyString),
7+
runId: Type.Optional(NonEmptyString),
8+
taskId: Type.Optional(NonEmptyString),
9+
},
10+
{ additionalProperties: false },
11+
);
12+
13+
export const ArtifactGetParamsSchema = Type.Intersect([
14+
ArtifactQueryParamsSchema,
15+
Type.Object(
16+
{
17+
artifactId: NonEmptyString,
18+
},
19+
{ additionalProperties: false },
20+
),
21+
]);
22+
23+
export const ArtifactSummarySchema = Type.Object(
24+
{
25+
id: NonEmptyString,
26+
type: NonEmptyString,
27+
title: NonEmptyString,
28+
mimeType: Type.Optional(NonEmptyString),
29+
sizeBytes: Type.Optional(Type.Integer({ minimum: 0 })),
30+
sessionKey: Type.Optional(NonEmptyString),
31+
runId: Type.Optional(NonEmptyString),
32+
taskId: Type.Optional(NonEmptyString),
33+
messageSeq: Type.Optional(Type.Integer({ minimum: 1 })),
34+
source: Type.Optional(NonEmptyString),
35+
download: Type.Object(
36+
{
37+
mode: Type.Union([Type.Literal("bytes"), Type.Literal("url"), Type.Literal("unsupported")]),
38+
},
39+
{ additionalProperties: false },
40+
),
41+
},
42+
{ additionalProperties: false },
43+
);
44+
45+
export const ArtifactsListParamsSchema = ArtifactQueryParamsSchema;
46+
47+
export const ArtifactsListResultSchema = Type.Object(
48+
{
49+
artifacts: Type.Array(ArtifactSummarySchema),
50+
},
51+
{ additionalProperties: false },
52+
);
53+
54+
export const ArtifactsGetParamsSchema = ArtifactGetParamsSchema;
55+
56+
export const ArtifactsGetResultSchema = Type.Object(
57+
{
58+
artifact: ArtifactSummarySchema,
59+
},
60+
{ additionalProperties: false },
61+
);
62+
63+
export const ArtifactsDownloadParamsSchema = ArtifactGetParamsSchema;
64+
65+
export const ArtifactsDownloadResultSchema = Type.Object(
66+
{
67+
artifact: ArtifactSummarySchema,
68+
encoding: Type.Optional(Type.Literal("base64")),
69+
data: Type.Optional(Type.String()),
70+
url: Type.Optional(NonEmptyString),
71+
},
72+
{ additionalProperties: false },
73+
);

src/gateway/protocol/schema/protocol-schemas.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ import {
4949
ToolsEffectiveParamsSchema,
5050
ToolsEffectiveResultSchema,
5151
} from "./agents-models-skills.js";
52+
import {
53+
ArtifactSummarySchema,
54+
ArtifactsDownloadParamsSchema,
55+
ArtifactsDownloadResultSchema,
56+
ArtifactsGetParamsSchema,
57+
ArtifactsGetResultSchema,
58+
ArtifactsListParamsSchema,
59+
ArtifactsListResultSchema,
60+
} from "./artifacts.js";
5261
import {
5362
ChannelsStartParamsSchema,
5463
ChannelsLogoutParamsSchema,
@@ -333,6 +342,13 @@ export const ProtocolSchemas = {
333342
AgentsFilesGetResult: AgentsFilesGetResultSchema,
334343
AgentsFilesSetParams: AgentsFilesSetParamsSchema,
335344
AgentsFilesSetResult: AgentsFilesSetResultSchema,
345+
ArtifactSummary: ArtifactSummarySchema,
346+
ArtifactsListParams: ArtifactsListParamsSchema,
347+
ArtifactsListResult: ArtifactsListResultSchema,
348+
ArtifactsGetParams: ArtifactsGetParamsSchema,
349+
ArtifactsGetResult: ArtifactsGetResultSchema,
350+
ArtifactsDownloadParams: ArtifactsDownloadParamsSchema,
351+
ArtifactsDownloadResult: ArtifactsDownloadResultSchema,
336352
AgentsListParams: AgentsListParamsSchema,
337353
AgentsListResult: AgentsListResultSchema,
338354
ModelChoice: ModelChoiceSchema,

src/gateway/protocol/schema/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export type AgentsFilesGetParams = SchemaType<"AgentsFilesGetParams">;
116116
export type AgentsFilesGetResult = SchemaType<"AgentsFilesGetResult">;
117117
export type AgentsFilesSetParams = SchemaType<"AgentsFilesSetParams">;
118118
export type AgentsFilesSetResult = SchemaType<"AgentsFilesSetResult">;
119+
export type ArtifactSummary = SchemaType<"ArtifactSummary">;
120+
export type ArtifactsListParams = SchemaType<"ArtifactsListParams">;
121+
export type ArtifactsListResult = SchemaType<"ArtifactsListResult">;
122+
export type ArtifactsGetParams = SchemaType<"ArtifactsGetParams">;
123+
export type ArtifactsGetResult = SchemaType<"ArtifactsGetResult">;
124+
export type ArtifactsDownloadParams = SchemaType<"ArtifactsDownloadParams">;
125+
export type ArtifactsDownloadResult = SchemaType<"ArtifactsDownloadResult">;
119126
export type AgentsListParams = SchemaType<"AgentsListParams">;
120127
export type AgentsListResult = SchemaType<"AgentsListResult">;
121128
export type ModelChoice = SchemaType<"ModelChoice">;

src/gateway/server-methods-list.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ const BASE_METHODS = [
7171
"agents.files.list",
7272
"agents.files.get",
7373
"agents.files.set",
74+
"artifacts.list",
75+
"artifacts.get",
76+
"artifacts.download",
7477
"skills.status",
7578
"skills.search",
7679
"skills.detail",

src/gateway/server-methods.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ErrorCodes, errorShape } from "./protocol/index.js";
66
import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js";
77
import { agentHandlers } from "./server-methods/agent.js";
88
import { agentsHandlers } from "./server-methods/agents.js";
9+
import { artifactsHandlers } from "./server-methods/artifacts.js";
910
import { channelsHandlers } from "./server-methods/channels.js";
1011
import { chatHandlers } from "./server-methods/chat.js";
1112
import { commandsHandlers } from "./server-methods/commands.js";
@@ -107,6 +108,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
107108
...usageHandlers,
108109
...agentHandlers,
109110
...agentsHandlers,
111+
...artifactsHandlers,
110112
};
111113

112114
export async function handleGatewayRequest(
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { artifactsHandlers, collectArtifactsFromMessages } from "./artifacts.js";
3+
4+
const hoisted = vi.hoisted(() => ({
5+
loadSessionEntry: vi.fn(),
6+
readSessionMessages: vi.fn(),
7+
resolveSessionKeyForRun: vi.fn(),
8+
}));
9+
10+
vi.mock("../session-utils.js", async () => {
11+
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
12+
return {
13+
...actual,
14+
loadSessionEntry: hoisted.loadSessionEntry,
15+
readSessionMessages: hoisted.readSessionMessages,
16+
};
17+
});
18+
19+
vi.mock("../server-session-key.js", async () => {
20+
const actual = await vi.importActual<typeof import("../server-session-key.js")>(
21+
"../server-session-key.js",
22+
);
23+
return {
24+
...actual,
25+
resolveSessionKeyForRun: hoisted.resolveSessionKeyForRun,
26+
};
27+
});
28+
29+
function createResponder() {
30+
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
31+
return {
32+
calls,
33+
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
34+
calls.push({ ok, payload, error });
35+
},
36+
};
37+
}
38+
39+
describe("artifacts RPC handlers", () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
hoisted.loadSessionEntry.mockReturnValue({
43+
storePath: "/tmp/sessions.json",
44+
entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" },
45+
});
46+
hoisted.readSessionMessages.mockReturnValue([
47+
{
48+
role: "assistant",
49+
content: [
50+
{ type: "text", text: "see attached" },
51+
{
52+
type: "image",
53+
data: "aGVsbG8=",
54+
mimeType: "image/png",
55+
alt: "result.png",
56+
},
57+
],
58+
__openclaw: { seq: 2 },
59+
},
60+
]);
61+
});
62+
63+
it("lists stable transcript artifact summaries by sessionKey", () => {
64+
const { calls, respond } = createResponder();
65+
66+
artifactsHandlers["artifacts.list"]?.({
67+
req: { id: 1, method: "artifacts.list", params: {} },
68+
params: { sessionKey: "agent:main:main" },
69+
client: null,
70+
isWebchatConnect: () => false,
71+
respond,
72+
context: {} as never,
73+
});
74+
75+
expect(calls).toHaveLength(1);
76+
expect(calls[0]?.ok).toBe(true);
77+
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
78+
expect(payload.artifacts).toHaveLength(1);
79+
expect(payload.artifacts?.[0]).toMatchObject({
80+
type: "image",
81+
title: "result.png",
82+
mimeType: "image/png",
83+
sizeBytes: 5,
84+
sessionKey: "agent:main:main",
85+
messageSeq: 2,
86+
source: "session-transcript",
87+
download: { mode: "bytes" },
88+
});
89+
expect(payload.artifacts?.[0]?.id).toMatch(/^artifact_/);
90+
expect(payload.artifacts?.[0]).not.toHaveProperty("data");
91+
});
92+
93+
it("gets and downloads an inline artifact", () => {
94+
const listed = collectArtifactsFromMessages({
95+
sessionKey: "agent:main:main",
96+
messages: hoisted.readSessionMessages(),
97+
});
98+
const artifactId = listed[0]?.id;
99+
expect(artifactId).toBeTruthy();
100+
101+
const get = createResponder();
102+
artifactsHandlers["artifacts.get"]?.({
103+
req: { id: 1, method: "artifacts.get", params: {} },
104+
params: { sessionKey: "agent:main:main", artifactId },
105+
client: null,
106+
isWebchatConnect: () => false,
107+
respond: get.respond,
108+
context: {} as never,
109+
});
110+
expect(get.calls[0]?.ok).toBe(true);
111+
expect(get.calls[0]?.payload).toMatchObject({
112+
artifact: { id: artifactId, download: { mode: "bytes" } },
113+
});
114+
115+
const download = createResponder();
116+
artifactsHandlers["artifacts.download"]?.({
117+
req: { id: 1, method: "artifacts.download", params: {} },
118+
params: { sessionKey: "agent:main:main", artifactId },
119+
client: null,
120+
isWebchatConnect: () => false,
121+
respond: download.respond,
122+
context: {} as never,
123+
});
124+
expect(download.calls[0]?.ok).toBe(true);
125+
expect(download.calls[0]?.payload).toMatchObject({
126+
encoding: "base64",
127+
data: "aGVsbG8=",
128+
artifact: { id: artifactId },
129+
});
130+
});
131+
132+
it("resolves runId queries through the gateway run-to-session lookup", () => {
133+
hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main");
134+
const { calls, respond } = createResponder();
135+
136+
artifactsHandlers["artifacts.list"]?.({
137+
req: { id: 1, method: "artifacts.list", params: {} },
138+
params: { runId: "run-1" },
139+
client: null,
140+
isWebchatConnect: () => false,
141+
respond,
142+
context: {} as never,
143+
});
144+
145+
expect(calls[0]?.ok).toBe(true);
146+
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1");
147+
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
148+
expect(payload.artifacts?.[0]).toMatchObject({ runId: "run-1" });
149+
});
150+
151+
it("returns typed errors for missing query scope and missing artifacts", () => {
152+
const missingScope = createResponder();
153+
artifactsHandlers["artifacts.list"]?.({
154+
req: { id: 1, method: "artifacts.list", params: {} },
155+
params: {},
156+
client: null,
157+
isWebchatConnect: () => false,
158+
respond: missingScope.respond,
159+
context: {} as never,
160+
});
161+
expect(missingScope.calls[0]?.ok).toBe(false);
162+
expect(missingScope.calls[0]?.error).toMatchObject({
163+
details: { type: "artifact_query_unsupported" },
164+
});
165+
166+
const notFound = createResponder();
167+
artifactsHandlers["artifacts.get"]?.({
168+
req: { id: 1, method: "artifacts.get", params: {} },
169+
params: { sessionKey: "agent:main:main", artifactId: "artifact_missing" },
170+
client: null,
171+
isWebchatConnect: () => false,
172+
respond: notFound.respond,
173+
context: {} as never,
174+
});
175+
expect(notFound.calls[0]?.ok).toBe(false);
176+
expect(notFound.calls[0]?.error).toMatchObject({
177+
details: { type: "artifact_not_found", artifactId: "artifact_missing" },
178+
});
179+
});
180+
});

0 commit comments

Comments
 (0)