Skip to content

Commit 3adbac9

Browse files
authored
fix(backends): mapCommonBackendEvent unwraps sandbox-SDK data.part.text natively (closes #35) (#36)
Closes #35. The canonical `@tangle-network/sandbox` `box.streamTask` emits `message.part.updated` with the text nested under `data.part.text`: { type: 'message.part.updated', data: { part: { type: 'text', text: '…' } } } Before this fix, the default `mapCommonBackendEvent` only walked `data.text` / `data.delta` / `record.text` — sandbox-SDK text deltas silently dropped out of the canonical stream, forcing every sandbox-SDK consumer (blueprint-agent#1758, future gtm-agent sandbox path, every sandbox-hosted product downstream) to ship a duplicated `mapEvent` shim that walked `data.part.text` themselves. Fix: the `message.part.updated` branch now ALSO walks `data.part.text` when `data.part.type === 'text'` (or undefined for forward-compat). Resolution order is `data.text` → `data.delta` → `record.text` → `data.part.text` so any consumer already passing the flat shape keeps working unchanged (covered by the new precedence test). Two new tests pin the behaviour: - "unwraps the @tangle-network/sandbox `data.part.text` shape natively" — fires the real sandbox-SDK shape through `runAgentTaskStream` and asserts the canonical `text_delta` stream contains `['hello', ' world']`, plus that a non-text part-kind drops cleanly (does NOT mis-fire as a text_delta). - "prefers explicit `data.text` when both shapes present" — back-compat: any existing call site sending `{ data: { text } }` keeps winning over the nested shape. Result: products consuming the sandbox SDK can wire it straight into `createSandboxPromptBackend` without writing a `mapEvent` shim — the default does the right thing. Blueprint's pinned contract test (blueprint-agent#1758, `Claude-Code → RuntimeStreamEvent migration seam`) that documented this gap as a finding now becomes the regression guard on this fix. Verification ──────────── - `pnpm test` — 181/181 pass (+2 new). - `pnpm typecheck` — clean.
1 parent 236ae50 commit 3adbac9

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

src/backends.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,19 @@ function mapCommonBackendEvent(
299299
? (record.data as Record<string, unknown>)
300300
: record
301301
if (type === 'message.part.updated' || type === 'text_delta' || type === 'delta') {
302-
const text = stringValue(data.text) ?? stringValue(data.delta) ?? stringValue(record.text)
302+
// `@tangle-network/sandbox` `box.streamTask` emits `message.part.updated`
303+
// with a nested part: `{ type: 'message.part.updated', data: { part:
304+
// { type: 'text', text: '…' } } }`. Walk into `data.part.text` so the
305+
// canonical sandbox-SDK shape produces a `text_delta` natively — no
306+
// per-product `mapEvent` shim required. Tool parts are picked up by
307+
// the `tool_call` / `tool_result` branches below; non-text parts here
308+
// fall through to `undefined` (the consumer can opt in via `mapEvent`).
309+
const part = data.part as Record<string, unknown> | undefined
310+
const partText =
311+
part !== undefined && typeof part === 'object' && (part.type === 'text' || part.type === undefined)
312+
? stringValue(part.text)
313+
: undefined
314+
const text = stringValue(data.text) ?? stringValue(data.delta) ?? stringValue(record.text) ?? partText
303315
return text
304316
? {
305317
type: 'text_delta',

tests/runtime.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,65 @@ describe('runAgentTask', () => {
495495
})
496496
})
497497

498+
it('unwraps the @tangle-network/sandbox `data.part.text` shape natively (no mapEvent shim needed)', async () => {
499+
// The sandbox SDK emits `message.part.updated` with the text nested
500+
// under `data.part.text` (the canonical sandbox-SDK shape). Before
501+
// this fix, the default mapper looked at `data.text`/`data.delta`
502+
// only and silently dropped every part-text event — forcing every
503+
// sandbox-SDK consumer to ship a `mapEvent` shim (see blueprint-
504+
// agent#1758, tangle-network/agent-runtime#35). This test pins the
505+
// native unwrap so a regression here breaks the entire sandbox-SDK
506+
// → agent-runtime chat-engine path.
507+
const backend = createSandboxPromptBackend({
508+
getBox: () => ({ id: 'box-sandbox-sdk' }),
509+
getSessionId: (box) => box.id,
510+
async *streamPrompt() {
511+
yield { type: 'message.part.updated', data: { part: { type: 'text', text: 'hello' } } }
512+
yield { type: 'message.part.updated', data: { part: { type: 'text', text: ' world' } } }
513+
// A part of a non-text kind should NOT produce a text_delta —
514+
// those are handled by the `tool_call`/`tool_result` branches
515+
// when the consumer streams the matching event types.
516+
yield { type: 'message.part.updated', data: { part: { type: 'tool', name: 'bash' } } }
517+
},
518+
})
519+
const events = await collect(
520+
runAgentTaskStream({
521+
task: { id: 'sandbox-sdk-shape', intent: 'inspect', requiredKnowledge: [readyReq] },
522+
backend,
523+
input: { message: 'go' },
524+
}),
525+
)
526+
527+
expect(
528+
events.filter((event) => event.type === 'text_delta').map((event) => event.text),
529+
).toEqual(['hello', ' world'])
530+
})
531+
532+
it('prefers explicit `data.text` when both `data.text` and `data.part.text` are present (back-compat)', async () => {
533+
// If a consumer happens to send both shapes (mixed bag), the flat
534+
// `data.text` wins — preserves the pre-existing behaviour for any
535+
// call site that was passing `{ data: { text } }` directly.
536+
const backend = createSandboxPromptBackend({
537+
getBox: () => ({ id: 'box-1' }),
538+
async *streamPrompt() {
539+
yield {
540+
type: 'message.part.updated',
541+
data: { text: 'flat-wins', part: { type: 'text', text: 'should-not-win' } },
542+
}
543+
},
544+
})
545+
const events = await collect(
546+
runAgentTaskStream({
547+
task: { id: 'precedence', intent: 'inspect', requiredKnowledge: [readyReq] },
548+
backend,
549+
input: { message: 'go' },
550+
}),
551+
)
552+
expect(
553+
events.filter((event) => event.type === 'text_delta').map((event) => event.text),
554+
).toEqual(['flat-wins'])
555+
})
556+
498557
it('parses OpenAI-compatible streamed chat completions', async () => {
499558
const backend = createOpenAICompatibleBackend({
500559
apiKey: 'sk-test',

0 commit comments

Comments
 (0)