@zhin.js/adapter-sandbox 6.0.5 → 7.0.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @zhin.js/adapter-process
2
2
 
3
+ ## 7.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [c8f4d45]
8
+ - @zhin.js/plugin-runtime@1.1.2
9
+ - @zhin.js/page@1.0.5
10
+ - @zhin.js/host-http@1.0.5
11
+ - @zhin.js/adapter@1.1.4
12
+ - @zhin.js/core@1.5.1
13
+
14
+ ## 7.0.0
15
+
16
+ ### Patch Changes
17
+
18
+ - 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
19
+
20
+ 全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
21
+
22
+ - **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
23
+ - **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
24
+ - **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
25
+ - **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
26
+ - **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
27
+
28
+ 迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
29
+
30
+ - Updated dependencies [7c1e63a]
31
+ - Updated dependencies [4fbff5d]
32
+ - Updated dependencies [5b94d9c]
33
+ - @zhin.js/adapter@1.1.3
34
+ - @zhin.js/core@1.5.0
35
+ - @zhin.js/client@2.1.4
36
+
3
37
  ## 6.0.5
4
38
 
5
39
  ### Patch Changes
@@ -10,9 +10,10 @@ import { resolveSandboxEndpoint, } from "../lib/protocol.js";
10
10
  export { SandboxWsEndpoint } from "../lib/endpoint.js";
11
11
  export default defineAdapter({
12
12
  capabilities: ['inbound', 'outbound'],
13
- // Console UI 直接渲染 base64 内联媒体;交互段由 Console 原生承载。
13
+ // Console UI 直连 URL 媒体、直接渲染 base64 内联媒体;path 由端点读盘
14
+ // 物化为 base64 内联(sandbox 无平台上传通道)。交互段由 Console 原生承载。
14
15
  segments: {
15
- outboundMedia: ['base64'],
16
+ outboundMedia: ['url', 'base64', 'path'],
16
17
  interactive: 'native',
17
18
  },
18
19
  create(context) {
@@ -15,9 +15,10 @@ export type { SandboxEndpointOptions } from '../src/endpoint.js';
15
15
 
16
16
  export default defineAdapter<SandboxAdapterConfig>({
17
17
  capabilities: ['inbound', 'outbound'],
18
- // Console UI 直接渲染 base64 内联媒体;交互段由 Console 原生承载。
18
+ // Console UI 直连 URL 媒体、直接渲染 base64 内联媒体;path 由端点读盘
19
+ // 物化为 base64 内联(sandbox 无平台上传通道)。交互段由 Console 原生承载。
19
20
  segments: {
20
- outboundMedia: ['base64'],
21
+ outboundMedia: ['url', 'base64', 'path'],
21
22
  interactive: 'native',
22
23
  },
23
24
  create(context) {
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxAdapterConfig, type SandboxWsSocket, } from './protocol.js';
1
+ export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxAdapterConfig, type SandboxWsSocket, } from './protocol.js';
2
2
  export { SandboxWsEndpoint, type SandboxEndpointOptions, } from './endpoint.js';
package/lib/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, } from './protocol.js';
1
+ export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, } from './protocol.js';
2
2
  export { SandboxWsEndpoint, } from './endpoint.js';
package/lib/protocol.d.ts CHANGED
@@ -54,6 +54,8 @@ export type SandboxOutboundChannel = {
54
54
  readonly endpoint?: string;
55
55
  readonly messageId?: string;
56
56
  };
57
+ /** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
58
+ export declare function normalizeSandboxOutboundSegments(segments: readonly unknown[]): unknown[];
57
59
  /**
58
60
  * Wire-encode an already-rendered outbound payload.
59
61
  * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
package/lib/protocol.js CHANGED
@@ -1,4 +1,8 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
+ import { readFileSync } from 'node:fs';
3
+ import { isMediaRef } from '@zhin.js/core';
4
+ import { formatCompact, getLogger } from '@zhin.js/logger';
5
+ const logger = getLogger('sandbox');
2
6
  export function resolveSandboxEndpoint(appConfig) {
3
7
  const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
4
8
  const fixedName = typeof appConfig.name === 'string' && appConfig.name
@@ -103,6 +107,98 @@ export function parseSandboxWsPayload(raw) {
103
107
  }
104
108
  return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
105
109
  }
110
+ const MEDIA_SEGMENT_TYPES = new Set(['image', 'audio', 'video', 'file']);
111
+ /**
112
+ * base64 内联值:Console UI 的 resolveMediaSrc 只识别 data: / base64://
113
+ * 前缀;裸 base64 补前缀(有 mime_type 时拼成可直转 data: URL 的形状)。
114
+ */
115
+ function toInlineBase64Value(value, mimeType) {
116
+ const trimmed = value.trim();
117
+ if (trimmed.startsWith('base64://') || trimmed.startsWith('data:'))
118
+ return trimmed;
119
+ return mimeType
120
+ ? `base64://${mimeType};base64,${trimmed}`
121
+ : `base64://${trimmed}`;
122
+ }
123
+ /**
124
+ * 出站媒体段归一(canonical MediaRef 唯一来源,不读 legacy url/file/base64/src):
125
+ * - kind=url → 浏览器直连 URL,原样透传;
126
+ * - kind=base64 → 内联直发(补 base64:// 前缀供 Console UI 解析);
127
+ * - kind=path → 读盘物化为 base64 内联(sandbox 无平台上传通道);
128
+ * - kind=file → sandbox 无不透明引用通道,丢弃。
129
+ * 无 canonical `data.media` 的媒体段一律 warn + 丢弃。
130
+ */
131
+ function normalizeMediaSegment(segment) {
132
+ const data = segment.data ?? {};
133
+ const media = data.media;
134
+ if (!isMediaRef(media)) {
135
+ logger.warn(formatCompact({
136
+ op: 'sandbox_outbound_media_dropped',
137
+ type: segment.type,
138
+ reason: 'missing_media_ref',
139
+ }));
140
+ return null;
141
+ }
142
+ if (media.kind === 'url')
143
+ return { type: segment.type, data };
144
+ if (media.kind === 'base64') {
145
+ return {
146
+ type: segment.type,
147
+ data: {
148
+ ...data,
149
+ media: { ...media, value: toInlineBase64Value(media.value, media.mime_type) },
150
+ },
151
+ };
152
+ }
153
+ if (media.kind === 'path') {
154
+ try {
155
+ const base64 = readFileSync(media.value).toString('base64');
156
+ return {
157
+ type: segment.type,
158
+ data: {
159
+ ...data,
160
+ media: {
161
+ ...media,
162
+ kind: 'base64',
163
+ value: toInlineBase64Value(base64, media.mime_type),
164
+ },
165
+ },
166
+ };
167
+ }
168
+ catch (err) {
169
+ logger.warn(formatCompact({
170
+ op: 'sandbox_outbound_media_dropped',
171
+ type: segment.type,
172
+ reason: 'path_read_failed',
173
+ error: err instanceof Error ? err.message : String(err),
174
+ }));
175
+ return null;
176
+ }
177
+ }
178
+ logger.warn(formatCompact({
179
+ op: 'sandbox_outbound_media_dropped',
180
+ type: segment.type,
181
+ reason: 'unsupported_media_kind',
182
+ }));
183
+ return null;
184
+ }
185
+ /** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
186
+ export function normalizeSandboxOutboundSegments(segments) {
187
+ const out = [];
188
+ for (const item of segments) {
189
+ if (item
190
+ && typeof item === 'object'
191
+ && !Array.isArray(item)
192
+ && MEDIA_SEGMENT_TYPES.has(String(item.type))) {
193
+ const normalized = normalizeMediaSegment(item);
194
+ if (normalized)
195
+ out.push(normalized);
196
+ continue;
197
+ }
198
+ out.push(item);
199
+ }
200
+ return out;
201
+ }
106
202
  /**
107
203
  * Wire-encode an already-rendered outbound payload.
108
204
  * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
@@ -130,7 +226,7 @@ export function formatSandboxOutbound(payload, channel = {}) {
130
226
  if (Array.isArray(payload)) {
131
227
  return JSON.stringify({
132
228
  ...stamp,
133
- content: payload,
229
+ content: normalizeSandboxOutboundSegments(payload),
134
230
  timestamp: Date.now(),
135
231
  });
136
232
  }
@@ -146,6 +242,9 @@ export function formatSandboxOutbound(payload, channel = {}) {
146
242
  return JSON.stringify({
147
243
  ...stamp,
148
244
  ...envelope,
245
+ ...(Array.isArray(envelope.content)
246
+ ? { content: normalizeSandboxOutboundSegments(envelope.content) }
247
+ : {}),
149
248
  type: envelope.type ?? stamp.type,
150
249
  id: envelope.id ?? stamp.id,
151
250
  timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "6.0.5",
3
+ "version": "7.0.1",
4
4
  "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -48,14 +48,14 @@
48
48
  "dependencies": {
49
49
  "lucide-react": "^0.525.0",
50
50
  "react": "^19.2.8",
51
- "@zhin.js/adapter": "1.1.2",
52
- "@zhin.js/client": "2.1.3",
51
+ "@zhin.js/adapter": "1.1.4",
52
+ "@zhin.js/client": "2.1.4",
53
53
  "@zhin.js/console-contract": "1.0.0",
54
- "@zhin.js/core": "1.4.3",
55
- "@zhin.js/host-http": "1.0.4",
54
+ "@zhin.js/core": "1.5.1",
55
+ "@zhin.js/host-http": "1.0.5",
56
56
  "@zhin.js/logger": "1.0.75",
57
- "@zhin.js/page": "1.0.4",
58
- "@zhin.js/plugin-runtime": "1.1.1"
57
+ "@zhin.js/page": "1.0.5",
58
+ "@zhin.js/plugin-runtime": "1.1.2"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/react": "^19.2.18",
@@ -63,18 +63,18 @@
63
63
  "typescript": "^6.0.3",
64
64
  "vitest": "^4.1.10",
65
65
  "ws": "^8.21.1",
66
- "@zhin.js/pagemanager": "2.0.9",
67
- "@zhin.js/runtime": "1.0.5"
66
+ "@zhin.js/pagemanager": "2.0.11",
67
+ "@zhin.js/runtime": "1.0.6"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "react": "^19.0.0",
71
- "@zhin.js/adapter": "1.1.2",
72
- "@zhin.js/client": "2.1.3",
71
+ "@zhin.js/adapter": "1.1.4",
72
+ "@zhin.js/client": "2.1.4",
73
73
  "@zhin.js/console-contract": "1.0.0",
74
- "@zhin.js/core": "1.4.3",
75
- "@zhin.js/host-http": "1.0.4",
76
- "@zhin.js/page": "1.0.4",
77
- "@zhin.js/plugin-runtime": "1.1.1"
74
+ "@zhin.js/core": "1.5.1",
75
+ "@zhin.js/host-http": "1.0.5",
76
+ "@zhin.js/page": "1.0.5",
77
+ "@zhin.js/plugin-runtime": "1.1.2"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "react": {
@@ -29,17 +29,17 @@ const RichTextEditor = forwardRef(({ placeholder = '输入消息...', onSend, on
29
29
  else if (el.classList.contains('editor-image')) {
30
30
  const imageUrl = el.dataset.url;
31
31
  text += `[image:${imageUrl}]`;
32
- segments.push({ type: 'image', data: { url: imageUrl } });
32
+ segments.push({ type: 'image', data: { media: { kind: 'url', value: imageUrl } } });
33
33
  }
34
34
  else if (el.classList.contains('editor-video')) {
35
35
  const u = el.dataset.url || '';
36
36
  text += `[video:${u}]`;
37
- segments.push({ type: 'video', data: { url: u } });
37
+ segments.push({ type: 'video', data: { media: { kind: 'url', value: u } } });
38
38
  }
39
39
  else if (el.classList.contains('editor-audio')) {
40
40
  const u = el.dataset.url || '';
41
41
  text += `[audio:${u}]`;
42
- segments.push({ type: 'audio', data: { url: u } });
42
+ segments.push({ type: 'audio', data: { media: { kind: 'url', value: u } } });
43
43
  }
44
44
  else if (el.classList.contains('editor-at')) {
45
45
  const name = el.dataset.name;
@@ -56,15 +56,15 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
56
56
  } else if (el.classList.contains('editor-image')) {
57
57
  const imageUrl = el.dataset.url
58
58
  text += `[image:${imageUrl}]`
59
- segments.push({ type: 'image', data: { url: imageUrl } })
59
+ segments.push({ type: 'image', data: { media: { kind: 'url', value: imageUrl } } })
60
60
  } else if (el.classList.contains('editor-video')) {
61
61
  const u = el.dataset.url || ''
62
62
  text += `[video:${u}]`
63
- segments.push({ type: 'video', data: { url: u } })
63
+ segments.push({ type: 'video', data: { media: { kind: 'url', value: u } } })
64
64
  } else if (el.classList.contains('editor-audio')) {
65
65
  const u = el.dataset.url || ''
66
66
  text += `[audio:${u}]`
67
- segments.push({ type: 'audio', data: { url: u } })
67
+ segments.push({ type: 'audio', data: { media: { kind: 'url', value: u } } })
68
68
  } else if (el.classList.contains('editor-at')) {
69
69
  const name = el.dataset.name
70
70
  const id = el.dataset.id
@@ -197,11 +197,11 @@ export default function Sandbox() {
197
197
  else if (match[2])
198
198
  segments.push({ type: 'face', data: { id: parseInt(match[2], 10) } });
199
199
  else if (match[3])
200
- segments.push({ type: 'image', data: { url: match[3] } });
200
+ segments.push({ type: 'image', data: { media: { kind: 'url', value: match[3] } } });
201
201
  else if (match[4])
202
- segments.push({ type: 'video', data: { url: match[4] } });
202
+ segments.push({ type: 'video', data: { media: { kind: 'url', value: match[4] } } });
203
203
  else if (match[5])
204
- segments.push({ type: 'audio', data: { url: match[5] } });
204
+ segments.push({ type: 'audio', data: { media: { kind: 'url', value: match[5] } } });
205
205
  lastIndex = regex.lastIndex;
206
206
  }
207
207
  if (lastIndex < text.length) {
@@ -212,9 +212,9 @@ export default function Sandbox() {
212
212
  }
213
213
  if (match[1]) segments.push({ type: 'mention', data: { target: match[1], name: match[1] } })
214
214
  else if (match[2]) segments.push({ type: 'face', data: { id: parseInt(match[2], 10) } })
215
- else if (match[3]) segments.push({ type: 'image', data: { url: match[3] } })
216
- else if (match[4]) segments.push({ type: 'video', data: { url: match[4] } })
217
- else if (match[5]) segments.push({ type: 'audio', data: { url: match[5] } })
215
+ else if (match[3]) segments.push({ type: 'image', data: { media: { kind: 'url', value: match[3] } } })
216
+ else if (match[4]) segments.push({ type: 'video', data: { media: { kind: 'url', value: match[4] } } })
217
+ else if (match[5]) segments.push({ type: 'audio', data: { media: { kind: 'url', value: match[5] } } })
218
218
  lastIndex = regex.lastIndex
219
219
  }
220
220
  if (lastIndex < text.length) {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  bindSandboxWsSocket,
3
3
  formatSandboxOutbound,
4
+ normalizeSandboxOutboundSegments,
4
5
  parseSandboxWsPayload,
5
6
  resolveSandboxEndpoint,
6
7
  whenWsOpen,
package/src/protocol.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
2
 
3
+ import { readFileSync } from 'node:fs';
4
+ import { isMediaRef } from '@zhin.js/core';
5
+ import { formatCompact, getLogger } from '@zhin.js/logger';
6
+
7
+ const logger = getLogger('sandbox');
8
+
3
9
  export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
4
10
 
5
11
  export interface MessageElement {
@@ -179,6 +185,100 @@ export type SandboxOutboundChannel = {
179
185
  readonly messageId?: string;
180
186
  };
181
187
 
188
+ const MEDIA_SEGMENT_TYPES = new Set(['image', 'audio', 'video', 'file']);
189
+
190
+ /**
191
+ * base64 内联值:Console UI 的 resolveMediaSrc 只识别 data: / base64://
192
+ * 前缀;裸 base64 补前缀(有 mime_type 时拼成可直转 data: URL 的形状)。
193
+ */
194
+ function toInlineBase64Value(value: string, mimeType?: string): string {
195
+ const trimmed = value.trim();
196
+ if (trimmed.startsWith('base64://') || trimmed.startsWith('data:')) return trimmed;
197
+ return mimeType
198
+ ? `base64://${mimeType};base64,${trimmed}`
199
+ : `base64://${trimmed}`;
200
+ }
201
+
202
+ /**
203
+ * 出站媒体段归一(canonical MediaRef 唯一来源,不读 legacy url/file/base64/src):
204
+ * - kind=url → 浏览器直连 URL,原样透传;
205
+ * - kind=base64 → 内联直发(补 base64:// 前缀供 Console UI 解析);
206
+ * - kind=path → 读盘物化为 base64 内联(sandbox 无平台上传通道);
207
+ * - kind=file → sandbox 无不透明引用通道,丢弃。
208
+ * 无 canonical `data.media` 的媒体段一律 warn + 丢弃。
209
+ */
210
+ function normalizeMediaSegment(segment: MessageElement): MessageElement | null {
211
+ const data = segment.data ?? {};
212
+ const media = data.media;
213
+ if (!isMediaRef(media)) {
214
+ logger.warn(formatCompact({
215
+ op: 'sandbox_outbound_media_dropped',
216
+ type: segment.type,
217
+ reason: 'missing_media_ref',
218
+ }));
219
+ return null;
220
+ }
221
+ if (media.kind === 'url') return { type: segment.type, data };
222
+ if (media.kind === 'base64') {
223
+ return {
224
+ type: segment.type,
225
+ data: {
226
+ ...data,
227
+ media: { ...media, value: toInlineBase64Value(media.value, media.mime_type) },
228
+ },
229
+ };
230
+ }
231
+ if (media.kind === 'path') {
232
+ try {
233
+ const base64 = readFileSync(media.value).toString('base64');
234
+ return {
235
+ type: segment.type,
236
+ data: {
237
+ ...data,
238
+ media: {
239
+ ...media,
240
+ kind: 'base64',
241
+ value: toInlineBase64Value(base64, media.mime_type),
242
+ },
243
+ },
244
+ };
245
+ } catch (err) {
246
+ logger.warn(formatCompact({
247
+ op: 'sandbox_outbound_media_dropped',
248
+ type: segment.type,
249
+ reason: 'path_read_failed',
250
+ error: err instanceof Error ? err.message : String(err),
251
+ }));
252
+ return null;
253
+ }
254
+ }
255
+ logger.warn(formatCompact({
256
+ op: 'sandbox_outbound_media_dropped',
257
+ type: segment.type,
258
+ reason: 'unsupported_media_kind',
259
+ }));
260
+ return null;
261
+ }
262
+
263
+ /** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
264
+ export function normalizeSandboxOutboundSegments(segments: readonly unknown[]): unknown[] {
265
+ const out: unknown[] = [];
266
+ for (const item of segments) {
267
+ if (
268
+ item
269
+ && typeof item === 'object'
270
+ && !Array.isArray(item)
271
+ && MEDIA_SEGMENT_TYPES.has(String((item as MessageElement).type))
272
+ ) {
273
+ const normalized = normalizeMediaSegment(item as MessageElement);
274
+ if (normalized) out.push(normalized);
275
+ continue;
276
+ }
277
+ out.push(item);
278
+ }
279
+ return out;
280
+ }
281
+
182
282
  /**
183
283
  * Wire-encode an already-rendered outbound payload.
184
284
  * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
@@ -205,7 +305,7 @@ export function formatSandboxOutbound(
205
305
  if (Array.isArray(payload)) {
206
306
  return JSON.stringify({
207
307
  ...stamp,
208
- content: payload,
308
+ content: normalizeSandboxOutboundSegments(payload),
209
309
  timestamp: Date.now(),
210
310
  });
211
311
  }
@@ -225,6 +325,9 @@ export function formatSandboxOutbound(
225
325
  return JSON.stringify({
226
326
  ...stamp,
227
327
  ...envelope,
328
+ ...(Array.isArray(envelope.content)
329
+ ? { content: normalizeSandboxOutboundSegments(envelope.content) }
330
+ : {}),
228
331
  type: envelope.type ?? stamp.type,
229
332
  id: envelope.id ?? stamp.id,
230
333
  timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),