Skip to content

Commit f6c3dc9

Browse files
feat(sandbox): accept PromptInputPart[] messages in prompt dispatch (#187)
1 parent 873aa8b commit f6c3dc9

4 files changed

Lines changed: 151 additions & 53 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@
358358
"@tangle-network/agent-knowledge": "^1.7.0",
359359
"@tangle-network/agent-runtime": "^0.79.3",
360360
"@tangle-network/brand": "^1.0.0",
361-
"@tangle-network/sandbox": "^0.9.7",
361+
"@tangle-network/sandbox": "^0.10.5",
362362
"@tangle-network/sandbox-ui": "^0.72.0",
363363
"@tangle-network/ui": "^11.0.0",
364364
"@testing-library/dom": "^10.4.1",

pnpm-lock.yaml

Lines changed: 44 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/sandbox/index.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
streamSandboxPrompt,
4242
resolveModel,
4343
flattenHistory,
44+
mergeHistoryIntoParts,
4445
mergeExtraMcp,
4546
attachReasoningEffort,
4647
syncSandboxMemberAdd,
@@ -65,6 +66,7 @@ import {
6566
SandboxRuntimeAuthRefreshError,
6667
type SandboxRuntimeConfig,
6768
type SecretStore,
69+
type PromptInputPart,
6870
} from './index'
6971
import { resolveTangleExecutionEnvironment } from '../runtime/model'
7072
import type {
@@ -502,13 +504,68 @@ describe('streamSandboxPrompt seam', () => {
502504
const [, opts] = (box.streamPrompt as ReturnType<typeof vi.fn>).mock.calls[0]!
503505
expect(opts.backend.interactions).toBeUndefined()
504506
})
507+
508+
it('forwards a PromptInputPart[] message to box.streamPrompt with history folded into the text part', async () => {
509+
async function* events() {
510+
yield { type: 'result' }
511+
}
512+
const box = fakeBox({ streamPrompt: vi.fn().mockReturnValue(events()) })
513+
const parts: PromptInputPart[] = [
514+
{ type: 'image', url: 'https://img/1.png' },
515+
{ type: 'text', text: 'describe this' },
516+
]
517+
for await (const _ of streamSandboxPrompt(shell(), box, parts, {
518+
history: [{ role: 'assistant', content: 'prior' }],
519+
})) {
520+
void _
521+
}
522+
const [prompt] = (box.streamPrompt as ReturnType<typeof vi.fn>).mock.calls[0]!
523+
expect(Array.isArray(prompt)).toBe(true)
524+
expect(prompt).toEqual([
525+
{ type: 'image', url: 'https://img/1.png' },
526+
{ type: 'text', text: 'Assistant: prior\n\nUser: describe this' },
527+
])
528+
})
505529
})
506530

507531
describe('pure seam helpers', () => {
508532
it('flattenHistory returns the bare message when no history', () => {
509533
expect(flattenHistory('x')).toBe('x')
510534
})
511535

536+
it('mergeHistoryIntoParts returns the same parts when there is no history', () => {
537+
const parts: PromptInputPart[] = [{ type: 'text', text: 'hi' }]
538+
expect(mergeHistoryIntoParts(parts)).toBe(parts)
539+
expect(mergeHistoryIntoParts(parts, [])).toBe(parts)
540+
})
541+
542+
it('mergeHistoryIntoParts folds the transcript into the first text part, preserving other parts and order', () => {
543+
const parts: PromptInputPart[] = [
544+
{ type: 'image', url: 'https://img/1.png' },
545+
{ type: 'text', text: 'describe this' },
546+
{ type: 'file', filename: 'notes.txt' },
547+
]
548+
const merged = mergeHistoryIntoParts(parts, [
549+
{ role: 'user', content: 'earlier question' },
550+
{ role: 'assistant', content: 'earlier answer' },
551+
])
552+
expect(merged).toEqual([
553+
{ type: 'image', url: 'https://img/1.png' },
554+
{
555+
type: 'text',
556+
text: 'User: earlier question\n\nAssistant: earlier answer\n\nUser: describe this',
557+
},
558+
{ type: 'file', filename: 'notes.txt' },
559+
])
560+
})
561+
562+
it('mergeHistoryIntoParts throws fail-loud when parts contain no text part', () => {
563+
const parts: PromptInputPart[] = [{ type: 'image', url: 'https://img/1.png' }]
564+
expect(() => mergeHistoryIntoParts(parts, [{ role: 'user', content: 'prior' }])).toThrow(
565+
/text part/,
566+
)
567+
})
568+
512569
it('mergeExtraMcp throws on a collision with app-tool or base profile servers', () => {
513570
const appTool: Record<string, AgentProfileMcpServer> = {
514571
'app-propose': {} as AgentProfileMcpServer,

src/sandbox/index.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,15 +1508,49 @@ export function resolveModel(
15081508
}
15091509
}
15101510

1511+
// The SDK's SandboxInstance.streamPrompt/.prompt accept `string | PromptInputPart[]`
1512+
// but the published package does not re-export the PromptInputPart type by name from
1513+
// any of its entry points, so it's derived structurally off the method signature
1514+
// itself — this stays in lockstep with the SDK's actual accepted shape.
1515+
export type PromptInputPart = Extract<
1516+
Parameters<SandboxInstance['streamPrompt']>[0],
1517+
readonly unknown[]
1518+
>[number]
1519+
1520+
function historyTranscript(
1521+
history: Array<{ role: 'user' | 'assistant'; content: string }>,
1522+
): string {
1523+
return history
1524+
.map((entry) => `${entry.role === 'assistant' ? 'Assistant' : 'User'}: ${entry.content}`)
1525+
.join('\n\n')
1526+
}
1527+
15111528
export function flattenHistory(
15121529
message: string,
15131530
history?: Array<{ role: 'user' | 'assistant'; content: string }>,
15141531
): string {
15151532
if (!history?.length) return message
1516-
const transcript = history
1517-
.map((entry) => `${entry.role === 'assistant' ? 'Assistant' : 'User'}: ${entry.content}`)
1518-
.join('\n\n')
1519-
return `${transcript}\n\nUser: ${message}`
1533+
return `${historyTranscript(history)}\n\nUser: ${message}`
1534+
}
1535+
1536+
/**
1537+
* History-aware equivalent of flattenHistory for multimodal prompt parts: the
1538+
* transcript is folded into the first text part (image/file parts carry no
1539+
* text to prepend to) rather than replacing the message wholesale.
1540+
*/
1541+
export function mergeHistoryIntoParts(
1542+
parts: PromptInputPart[],
1543+
history?: Array<{ role: 'user' | 'assistant'; content: string }>,
1544+
): PromptInputPart[] {
1545+
if (!history?.length) return parts
1546+
const textIndex = parts.findIndex((part) => part.type === 'text')
1547+
if (textIndex === -1) {
1548+
throw new Error('mergeHistoryIntoParts requires at least one text part to carry the history')
1549+
}
1550+
const textPart = parts[textIndex] as Extract<PromptInputPart, { type: 'text' }>
1551+
const merged = [...parts]
1552+
merged[textIndex] = { ...textPart, text: `${historyTranscript(history)}\n\nUser: ${textPart.text}` }
1553+
return merged
15201554
}
15211555

15221556
export function mergeExtraMcp(
@@ -1581,7 +1615,7 @@ type StreamPromptOptions = Parameters<SandboxInstance['streamPrompt']>[1]
15811615
export async function* streamSandboxPrompt(
15821616
shell: SandboxRuntimeConfig,
15831617
box: SandboxInstance,
1584-
message: string,
1618+
message: string | PromptInputPart[],
15851619
options?: StreamSandboxPromptOptions,
15861620
): AsyncGenerator<unknown> {
15871621
const harness = options?.harness ?? 'opencode'
@@ -1595,7 +1629,10 @@ export async function* streamSandboxPrompt(
15951629
// if the UI snap was bypassed. Provider-less ids pass (session's own config).
15961630
if (model?.model) assertHarnessModelCompatible(harness, model.model)
15971631

1598-
const prompt = flattenHistory(message, options?.history)
1632+
const prompt =
1633+
typeof message === 'string'
1634+
? flattenHistory(message, options?.history)
1635+
: mergeHistoryIntoParts(message, options?.history)
15991636

16001637
const appToolMcp = options?.appToolMcp ?? {}
16011638
const extraMcp = mergeExtraMcp(appToolMcp, options?.baseProfileMcp ?? {}, options?.extraMcp)
@@ -1645,7 +1682,7 @@ export async function* streamSandboxPrompt(
16451682
export async function runSandboxPrompt(
16461683
shell: SandboxRuntimeConfig,
16471684
box: SandboxInstance,
1648-
message: string,
1685+
message: string | PromptInputPart[],
16491686
options?: StreamSandboxPromptOptions,
16501687
): Promise<string> {
16511688
let fullText = ''
@@ -1816,7 +1853,7 @@ export async function mintSandboxScopedToken(
18161853
export async function driveSandboxTurn(
18171854
shell: SandboxRuntimeConfig,
18181855
box: SandboxInstance,
1819-
message: string,
1856+
message: string | PromptInputPart[],
18201857
options: StreamSandboxPromptOptions & { sessionId: string },
18211858
): Promise<Outcome<PromptResult>> {
18221859
const harness = options.harness ?? 'opencode'
@@ -1825,7 +1862,10 @@ export async function driveSandboxTurn(
18251862
modelApiKey: options.modelApiKey,
18261863
})
18271864
if (model?.model) assertHarnessModelCompatible(harness, model.model)
1828-
const prompt = flattenHistory(message, options.history)
1865+
const prompt =
1866+
typeof message === 'string'
1867+
? flattenHistory(message, options.history)
1868+
: mergeHistoryIntoParts(message, options.history)
18291869
const appToolMcp = options.appToolMcp ?? {}
18301870
const extraMcp = mergeExtraMcp(appToolMcp, options.baseProfileMcp ?? {}, options.extraMcp)
18311871
const profile = attachReasoningEffort(

0 commit comments

Comments
 (0)