@clawling/clawchat-plugin-openclaw 2026.5.12-28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ inferMediaKindFromMime,
4
+ fetchInboundMedia,
5
+ uploadOutboundMedia,
6
+ type MediaItem,
7
+ } from "./media-runtime.ts";
8
+
9
+ describe("inferMediaKindFromMime", () => {
10
+ it("maps image/* to image", () => {
11
+ expect(inferMediaKindFromMime("image/png")).toBe("image");
12
+ expect(inferMediaKindFromMime("image/jpeg")).toBe("image");
13
+ });
14
+ it("maps audio/* to audio", () => {
15
+ expect(inferMediaKindFromMime("audio/mpeg")).toBe("audio");
16
+ });
17
+ it("maps video/* to video", () => {
18
+ expect(inferMediaKindFromMime("video/mp4")).toBe("video");
19
+ });
20
+ it("maps anything else to file", () => {
21
+ expect(inferMediaKindFromMime("application/pdf")).toBe("file");
22
+ expect(inferMediaKindFromMime("text/plain")).toBe("file");
23
+ });
24
+ it("maps undefined / empty to file", () => {
25
+ expect(inferMediaKindFromMime(undefined)).toBe("file");
26
+ expect(inferMediaKindFromMime("")).toBe("file");
27
+ });
28
+ });
29
+
30
+ describe("fetchInboundMedia", () => {
31
+ function buildRuntime() {
32
+ const fetchRemoteMedia = vi.fn().mockImplementation(async ({ url }: { url: string }) => ({
33
+ buffer: Buffer.from(`bytes-of-${url}`),
34
+ contentType: "image/png",
35
+ fileName: "x.png",
36
+ }));
37
+ const saveMediaBuffer = vi
38
+ .fn()
39
+ .mockImplementation(
40
+ async (_buf: Buffer, _ct?: string, _sub?: string, _max?: number, name?: string) => ({
41
+ path: `/cache/${name ?? "auto"}`,
42
+ contentType: "image/png",
43
+ }),
44
+ );
45
+ const runtime = {
46
+ channel: {
47
+ media: { fetchRemoteMedia, saveMediaBuffer },
48
+ },
49
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
50
+ return { runtime, fetchRemoteMedia, saveMediaBuffer };
51
+ }
52
+
53
+ it("fetches each item and returns local paths", async () => {
54
+ const { runtime } = buildRuntime();
55
+ const items: MediaItem[] = [
56
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
57
+ { kind: "image", url: "https://cdn/b.png", mime: "image/png" },
58
+ ];
59
+ const paths = await fetchInboundMedia(items, { runtime, maxBytes: 1000 });
60
+ expect(paths).toEqual(["/cache/x.png", "/cache/x.png"]);
61
+ });
62
+
63
+ it("drops a single item that fails, keeps others", async () => {
64
+ const { runtime, fetchRemoteMedia } = buildRuntime();
65
+ fetchRemoteMedia.mockImplementationOnce(async () => {
66
+ throw new Error("network");
67
+ });
68
+ const items: MediaItem[] = [
69
+ { kind: "image", url: "https://cdn/bad.png" },
70
+ { kind: "image", url: "https://cdn/good.png" },
71
+ ];
72
+ const log = { info: vi.fn(), error: vi.fn() };
73
+ const paths = await fetchInboundMedia(items, { runtime, log });
74
+ expect(paths).toHaveLength(1);
75
+ expect(log.info).toHaveBeenCalled();
76
+ });
77
+
78
+ it("returns empty array for empty input", async () => {
79
+ const { runtime } = buildRuntime();
80
+ expect(await fetchInboundMedia([], { runtime })).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("uploadOutboundMedia", () => {
85
+ function buildApiClient() {
86
+ return {
87
+ uploadMedia: vi.fn().mockResolvedValue({
88
+ kind: "image",
89
+ url: "https://cdn/uploaded.png",
90
+ name: "uploaded.png",
91
+ size: 12,
92
+ mime: "image/png",
93
+ }),
94
+ } as unknown as ReturnType<typeof import("./api-client.ts").createOpenclawClawlingApiClient>;
95
+ }
96
+
97
+ function buildRuntime() {
98
+ const loadWebMedia = vi.fn().mockResolvedValue({
99
+ buffer: Buffer.from("loaded-bytes"),
100
+ contentType: "image/png",
101
+ fileName: "img.png",
102
+ });
103
+ const runtime = {
104
+ media: { loadWebMedia },
105
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
106
+ return { runtime, loadWebMedia };
107
+ }
108
+
109
+ it("loads each url and uploads", async () => {
110
+ const { runtime, loadWebMedia } = buildRuntime();
111
+ const apiClient = buildApiClient();
112
+ const fragments = await uploadOutboundMedia(["https://cdn/in.png", "https://cdn/in2.png"], {
113
+ apiClient,
114
+ runtime,
115
+ });
116
+ expect(loadWebMedia).toHaveBeenCalledTimes(2);
117
+ expect(fragments).toEqual([
118
+ {
119
+ kind: "image",
120
+ url: "https://cdn/uploaded.png",
121
+ mime: "image/png",
122
+ size: 12,
123
+ name: "uploaded.png",
124
+ },
125
+ {
126
+ kind: "image",
127
+ url: "https://cdn/uploaded.png",
128
+ mime: "image/png",
129
+ size: 12,
130
+ name: "uploaded.png",
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it("uses server-returned kind and name for uploaded media fragments", async () => {
136
+ const { runtime } = buildRuntime();
137
+ const apiClient = buildApiClient();
138
+ (apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
139
+ kind: "file",
140
+ url: "https://cdn/server.bin",
141
+ name: "server.bin",
142
+ size: 12,
143
+ mime: "image/png",
144
+ });
145
+
146
+ const fragments = await uploadOutboundMedia(["https://cdn/in.png"], {
147
+ apiClient,
148
+ runtime,
149
+ });
150
+
151
+ expect(fragments).toEqual([
152
+ {
153
+ kind: "file",
154
+ url: "https://cdn/server.bin",
155
+ mime: "image/png",
156
+ size: 12,
157
+ name: "server.bin",
158
+ },
159
+ ]);
160
+ });
161
+
162
+ it("passes host media access options to loadWebMedia", async () => {
163
+ const { runtime, loadWebMedia } = buildRuntime();
164
+ const apiClient = buildApiClient();
165
+ const readFile = vi.fn(async () => Buffer.from("host-read"));
166
+
167
+ await uploadOutboundMedia(["relative/image.png"], {
168
+ apiClient,
169
+ runtime,
170
+ mediaAccess: {
171
+ localRoots: ["/workspace"],
172
+ readFile,
173
+ workspaceDir: "/workspace",
174
+ },
175
+ });
176
+
177
+ expect(loadWebMedia).toHaveBeenCalledWith(
178
+ "relative/image.png",
179
+ expect.objectContaining({
180
+ localRoots: ["/workspace"],
181
+ readFile,
182
+ hostReadCapability: true,
183
+ workspaceDir: "/workspace",
184
+ }),
185
+ );
186
+ });
187
+
188
+ it("drops a single failed upload, returns the rest", async () => {
189
+ const { runtime } = buildRuntime();
190
+ const apiClient = buildApiClient();
191
+ (apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
192
+ const log = { info: vi.fn(), error: vi.fn() };
193
+ const fragments = await uploadOutboundMedia(["https://cdn/a.png", "https://cdn/b.png"], {
194
+ apiClient,
195
+ runtime,
196
+ log,
197
+ });
198
+ expect(fragments).toHaveLength(1);
199
+ expect(log.error).toHaveBeenCalled();
200
+ });
201
+
202
+ it("returns empty array for empty input", async () => {
203
+ const apiClient = buildApiClient();
204
+ const { runtime } = buildRuntime();
205
+ expect(await uploadOutboundMedia([], { apiClient, runtime })).toEqual([]);
206
+ });
207
+ });
@@ -0,0 +1,152 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import {
3
+ buildOutboundMediaLoadOptions,
4
+ type OutboundMediaAccess,
5
+ type OutboundMediaReadFile,
6
+ } from "openclaw/plugin-sdk/media-runtime";
7
+ import type { OpenclawClawlingApiClient } from "./api-client.ts";
8
+
9
+ /**
10
+ * Local structural superset of the protocol media fragment narrow types.
11
+ *
12
+ * Each media fragment has a literal `kind` that distinguishes it. Building the
13
+ * wide shape locally avoids per-kind switch statements when constructing
14
+ * outbound fragments; structural compatibility lets a single object satisfy
15
+ * whichever narrow type matches its runtime `kind`.
16
+ */
17
+ export interface MediaItem {
18
+ kind: "image" | "file" | "audio" | "video";
19
+ url: string;
20
+ name?: string;
21
+ mime?: string;
22
+ size?: number;
23
+ width?: number;
24
+ height?: number;
25
+ duration?: number;
26
+ }
27
+
28
+ /** Outbound fragment shape sent in `body.fragments`. Same wide shape as `MediaItem`. */
29
+ export type ClawlingMediaFragment = MediaItem;
30
+
31
+ export interface LogSink {
32
+ info?: (m: string) => void;
33
+ error?: (m: string) => void;
34
+ }
35
+
36
+ export interface FetchInboundCtx {
37
+ runtime: PluginRuntime;
38
+ log?: LogSink;
39
+ maxBytes?: number;
40
+ }
41
+
42
+ export interface UploadOutboundCtx {
43
+ apiClient: OpenclawClawlingApiClient;
44
+ runtime: PluginRuntime;
45
+ log?: LogSink;
46
+ maxBytes?: number;
47
+ /** Host-authorized outbound media access from OpenClaw message delivery. */
48
+ mediaAccess?: OutboundMediaAccess;
49
+ /** Allowed local roots for path-based uploads. Empty/undefined = use loadWebMedia defaults. */
50
+ mediaLocalRoots?: readonly string[] | "any";
51
+ /** Host-provided read bridge for sandbox/allowed local media paths. */
52
+ mediaReadFile?: OutboundMediaReadFile;
53
+ }
54
+
55
+ export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
56
+ if (!mime) return "file";
57
+ if (mime.startsWith("image/")) return "image";
58
+ if (mime.startsWith("audio/")) return "audio";
59
+ if (mime.startsWith("video/")) return "video";
60
+ return "file";
61
+ }
62
+
63
+ const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
64
+
65
+ /**
66
+ * Fetch each remote URL via the shared media runtime, persist to a local
67
+ * cache, and return the list of local paths.
68
+ *
69
+ * Failed items are logged at info level and dropped; the remaining items
70
+ * still resolve so a single bad URL doesn't blow up the whole inbound turn.
71
+ */
72
+ export async function fetchInboundMedia(
73
+ items: MediaItem[],
74
+ ctx: FetchInboundCtx,
75
+ ): Promise<string[]> {
76
+ if (items.length === 0) return [];
77
+ const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
78
+ const paths: string[] = [];
79
+ for (const item of items) {
80
+ try {
81
+ const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
82
+ url: item.url,
83
+ maxBytes,
84
+ });
85
+ const saved = await ctx.runtime.channel.media.saveMediaBuffer(
86
+ fetched.buffer,
87
+ fetched.contentType ?? item.mime,
88
+ "clawchat-plugin-openclaw-inbound",
89
+ maxBytes,
90
+ item.name ?? fetched.fileName,
91
+ );
92
+ paths.push(saved.path);
93
+ } catch (err) {
94
+ ctx.log?.info?.(
95
+ `clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`,
96
+ );
97
+ }
98
+ }
99
+ return paths;
100
+ }
101
+
102
+ /**
103
+ * Upload each URL (remote or local path) to /media/upload via the api
104
+ * client and return a fragment ready to splice into `body.fragments`.
105
+ *
106
+ * Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
107
+ * enforcement and media-loading policy stay aligned with the current
108
+ * OpenClaw runtime instead of a directly imported helper.
109
+ *
110
+ * Single-upload failures log at error and are dropped; the remaining
111
+ * fragments still come back so a partially-failing batch still sends the
112
+ * working media.
113
+ */
114
+ export async function uploadOutboundMedia(
115
+ urls: string[],
116
+ ctx: UploadOutboundCtx,
117
+ ): Promise<ClawlingMediaFragment[]> {
118
+ if (urls.length === 0) return [];
119
+ const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
120
+ const out: ClawlingMediaFragment[] = [];
121
+ for (const url of urls) {
122
+ try {
123
+ const loaded = await ctx.runtime.media.loadWebMedia(
124
+ url,
125
+ buildOutboundMediaLoadOptions({
126
+ maxBytes,
127
+ ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
128
+ ...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
129
+ ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
130
+ }),
131
+ );
132
+ const uploaded = await ctx.apiClient.uploadMedia({
133
+ buffer: loaded.buffer,
134
+ filename: loaded.fileName ?? "upload.bin",
135
+ mime: loaded.contentType,
136
+ });
137
+ const fragment: ClawlingMediaFragment = {
138
+ kind: uploaded.kind,
139
+ url: uploaded.url,
140
+ name: uploaded.name,
141
+ mime: uploaded.mime,
142
+ size: uploaded.size,
143
+ };
144
+ out.push(fragment);
145
+ } catch (err) {
146
+ ctx.log?.error?.(
147
+ `clawchat-plugin-openclaw outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`,
148
+ );
149
+ }
150
+ }
151
+ return out;
152
+ }
@@ -0,0 +1,201 @@
1
+ import type { Fragment } from "./protocol-types.ts";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ buildMentionMessageFragments,
5
+ extractMediaFragments,
6
+ fragmentsToText,
7
+ normalizeMentionTargets,
8
+ textToFragments,
9
+ } from "./message-mapper.ts";
10
+
11
+ describe("clawchat-plugin-openclaw message-mapper", () => {
12
+ it("flattens text fragments", () => {
13
+ const fragments: Fragment[] = [
14
+ { kind: "text", text: "hello " },
15
+ { kind: "text", text: "world" },
16
+ ];
17
+ expect(fragmentsToText(fragments)).toBe("hello world");
18
+ });
19
+
20
+ it("renders mention fragments using display text", () => {
21
+ const fragments: Fragment[] = [
22
+ { kind: "text", text: "hi " },
23
+ { kind: "mention", display: "@Clawling Assistant" } as unknown as Fragment,
24
+ { kind: "text", text: "!" },
25
+ ];
26
+ expect(fragmentsToText(fragments)).toBe("hi @Clawling Assistant!");
27
+ });
28
+
29
+ it("adds visible @ for mention display labels that omit it", () => {
30
+ const fragments: Fragment[] = [
31
+ { kind: "text", text: "请 " },
32
+ { kind: "mention", user_id: "usr_bob_456", display: "Bob" } as unknown as Fragment,
33
+ { kind: "text", text: " 下午处理" },
34
+ ];
35
+ expect(fragmentsToText(fragments)).toBe("请 @Bob 下午处理");
36
+ });
37
+
38
+ it("falls back to mention ids when display missing", () => {
39
+ const fragments: Fragment[] = [
40
+ { kind: "text", text: "hi " },
41
+ { kind: "mention", user_id: "agent-1" } as unknown as Fragment,
42
+ ];
43
+ expect(fragmentsToText(fragments, { mentionFallbackIds: ["agent-1"] })).toBe("hi @agent-1");
44
+ });
45
+
46
+ it("normalizes mention targets with optional display and dedupe", () => {
47
+ expect(
48
+ normalizeMentionTargets([
49
+ { userId: " user-a ", display: "Alice" },
50
+ { userId: "user-a", display: "Ignored" },
51
+ { userId: "user-b", display: "@Bob" },
52
+ { userId: "user-c" },
53
+ ]),
54
+ ).toEqual([
55
+ { userId: "user-a", display: "Alice" },
56
+ { userId: "user-b", display: "Bob" },
57
+ { userId: "user-c" },
58
+ ]);
59
+ });
60
+
61
+ it("builds mention fragments followed by spaced text", () => {
62
+ expect(
63
+ buildMentionMessageFragments({
64
+ mentions: [{ userId: "user-a", display: "Alice" }],
65
+ text: "请看这个",
66
+ }),
67
+ ).toEqual([
68
+ { kind: "mention", user_id: "user-a", display: "Alice" },
69
+ { kind: "text", text: " 请看这个" },
70
+ ]);
71
+ });
72
+
73
+ it("uses leading visible @text as mention display without duplicating text", () => {
74
+ expect(
75
+ buildMentionMessageFragments({
76
+ mentions: [{ userId: "user-a" }],
77
+ text: "@Alice",
78
+ }),
79
+ ).toEqual([
80
+ { kind: "mention", user_id: "user-a", display: "Alice" },
81
+ ]);
82
+ });
83
+
84
+ it("keeps spaces in a text-only mention display label", () => {
85
+ expect(
86
+ buildMentionMessageFragments({
87
+ mentions: [{ userId: "user-a" }],
88
+ text: "@Super Zero",
89
+ }),
90
+ ).toEqual([
91
+ { kind: "mention", user_id: "user-a", display: "Super Zero" },
92
+ ]);
93
+ });
94
+
95
+ it("throws when mention targets are missing user ids", () => {
96
+ expect(() => normalizeMentionTargets([{ userId: " " }])).toThrow(
97
+ "clawchat_mention_message requires mentions[0].userId",
98
+ );
99
+ });
100
+
101
+ it("renders image fragments inline and skips unknown kinds without crashing", () => {
102
+ const fragments: Fragment[] = [
103
+ { kind: "text", text: "see " },
104
+ { kind: "image", url: "https://example/i.png" } as unknown as Fragment,
105
+ { kind: "text", text: "this" },
106
+ ];
107
+ expect(fragmentsToText(fragments)).toBe("see ![image](https://example/i.png)this");
108
+ });
109
+
110
+ it("trims the final result", () => {
111
+ const fragments: Fragment[] = [{ kind: "text", text: " hi " }];
112
+ expect(fragmentsToText(fragments)).toBe("hi");
113
+ });
114
+
115
+ it("textToFragments produces a single text fragment", () => {
116
+ expect(textToFragments("ok")).toEqual([{ kind: "text", text: "ok" }]);
117
+ });
118
+
119
+ it("textToFragments returns empty array for empty input", () => {
120
+ expect(textToFragments("")).toEqual([]);
121
+ expect(textToFragments(" ")).toEqual([]);
122
+ });
123
+
124
+ it("renders image fragments as markdown image", () => {
125
+ const fragments: Fragment[] = [
126
+ { kind: "text", text: "look: " },
127
+ { kind: "image", url: "https://cdn/x.png", name: "logo" } as unknown as Fragment,
128
+ ];
129
+ expect(fragmentsToText(fragments)).toBe("look: ![logo](https://cdn/x.png)");
130
+ });
131
+
132
+ it("renders file/audio/video fragments as markdown link", () => {
133
+ const fragments: Fragment[] = [
134
+ { kind: "file", url: "https://cdn/a.pdf", name: "doc.pdf" } as unknown as Fragment,
135
+ { kind: "audio", url: "https://cdn/b.mp3" } as unknown as Fragment,
136
+ { kind: "video", url: "https://cdn/c.mp4", name: "demo" } as unknown as Fragment,
137
+ ];
138
+ expect(fragmentsToText(fragments)).toBe(
139
+ "[doc.pdf](https://cdn/a.pdf)[audio](https://cdn/b.mp3)[demo](https://cdn/c.mp4)",
140
+ );
141
+ });
142
+
143
+ it("renders image without name as ![image](url)", () => {
144
+ const fragments: Fragment[] = [
145
+ { kind: "image", url: "https://cdn/x.png" } as unknown as Fragment,
146
+ ];
147
+ expect(fragmentsToText(fragments)).toBe("![image](https://cdn/x.png)");
148
+ });
149
+
150
+ it("extractMediaFragments returns only media fragments", () => {
151
+ const fragments: Fragment[] = [
152
+ { kind: "text", text: "hi " },
153
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" } as unknown as Fragment,
154
+ { kind: "mention", display: "@bob" } as unknown as Fragment,
155
+ { kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" } as unknown as Fragment,
156
+ ];
157
+ const items = extractMediaFragments(fragments);
158
+ expect(items).toEqual([
159
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
160
+ { kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" },
161
+ ]);
162
+ });
163
+
164
+ it("extractMediaFragments preserves all optional metadata", () => {
165
+ const fragments: Fragment[] = [
166
+ {
167
+ kind: "video",
168
+ url: "https://cdn/v.mp4",
169
+ name: "v",
170
+ mime: "video/mp4",
171
+ size: 1234,
172
+ width: 1280,
173
+ height: 720,
174
+ duration: 30,
175
+ } as unknown as Fragment,
176
+ ];
177
+ expect(extractMediaFragments(fragments)).toEqual([
178
+ {
179
+ kind: "video",
180
+ url: "https://cdn/v.mp4",
181
+ name: "v",
182
+ mime: "video/mp4",
183
+ size: 1234,
184
+ width: 1280,
185
+ height: 720,
186
+ duration: 30,
187
+ },
188
+ ]);
189
+ });
190
+
191
+ it("extractMediaFragments skips media fragments without url", () => {
192
+ // Note: the protocol type requires `url` on image fragments — using a partial
193
+ // object here exercises the runtime guard. Casting here is intentional
194
+ // (we want to verify defensive handling of malformed input).
195
+ const fragments: Fragment[] = [
196
+ { kind: "image" } as unknown as Fragment,
197
+ { kind: "image", url: "https://cdn/a.png" } as unknown as Fragment,
198
+ ];
199
+ expect(extractMediaFragments(fragments)).toEqual([{ kind: "image", url: "https://cdn/a.png" }]);
200
+ });
201
+ });