2026-07-14 03:37:51 +08:00
/**
* Model-facing result rendering for the bash tool.
*
* @module @deepseek-ai/dsh-tool-bash/render
*/
2026-07-15 13:38:17 +08:00
import type { BashProcessRead , BashRunResult , BashSandboxInfo , CollectedOutput } from '@deepseek-ai/dsh-bash'
2026-07-14 03:37:51 +08:00
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
2026-07-15 21:39:02 +08:00
import { escalationHintMarker , sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
2026-07-14 03:37:51 +08:00
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText ( output : CollectedOutput ) : string {
if ( ! output . truncated ) return output . text
return ` ${ output . text } \ n[output truncated; full output: ${ output . spillPath ? ? '(unavailable)' } ] `
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
2026-07-15 21:08:58 +08:00
* stderr section, then exit-status markers. Non-zero exits are reported, not
2026-07-14 03:37:51 +08:00
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderResult (
result : BashRunResult ,
escalationModes : readonly SandboxMode [ ] = [ ] ,
) : string {
const out = streamText ( result . stdout )
const err = streamText ( result . stderr )
let body = out
if ( err . length > 0 ) {
// Single newline between sections (stdout usually ends with one already).
if ( body . length > 0 && ! body . endsWith ( '\n' ) ) body += '\n'
body += ` [stderr] \ n ${ err } `
}
if ( body . length === 0 ) body = '(no output)'
const markers : string [ ] = [ ]
2026-07-15 21:08:58 +08:00
// Keep the exit marker last because parseExitStatus anchors there.
2026-07-14 03:37:51 +08:00
if ( result . sandbox ? . denied ) {
2026-07-15 21:39:02 +08:00
markers . push ( sandboxDenialMarker ( result . sandbox . mode ) )
2026-07-15 21:08:58 +08:00
// Hint only when the composition exposes escalation, before the final exit marker.
2026-07-14 03:37:51 +08:00
if ( escalationModes . length > 0 ) {
2026-07-15 21:39:02 +08:00
markers . push ( escalationHintMarker ( 'command' ) )
2026-07-14 03:37:51 +08:00
}
}
2026-07-15 21:08:58 +08:00
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
2026-07-14 03:37:51 +08:00
if ( result . timedOut ) markers . push ( ` [timed out after ${ result . timeoutMs } ms] ` )
if ( result . signal !== null ) {
markers . push ( ` [killed by signal: ${ result . signal } ] ` )
} else if ( result . exitCode !== 0 ) {
markers . push ( ` [exit code: ${ result . exitCode } ] ` )
}
if ( markers . length === 0 ) return body
if ( ! body . endsWith ( '\n' ) ) body += '\n'
return body + markers . join ( '\n' )
}
2026-07-14 03:45:22 +08:00
2026-07-15 13:38:17 +08:00
/**
* Shape one background-process read into the `task_output` delta the model
* sees: the incremental delta, plus the lossy-read notice (with full-stream
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
* rendering (`(no new output)`) is the generic control surface's job.
* @param read - one incremental read from the process handle.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderProcessRead (
read : BashProcessRead ,
sandbox? : BashSandboxInfo ,
escalationModes : readonly SandboxMode [ ] = [ ] ,
) : string {
const notices : string [ ] = [ ]
if ( read . lossy ) {
const paths = [ read . stdoutSpillPath , read . stderrSpillPath ] . filter ( ( path ) : path is string = > path !== undefined )
notices . push ( ` [some output was dropped from memory; full output: ${ paths . length > 0 ? paths . join ( ', ' ) : '(unavailable)' } ] ` )
}
if ( sandbox ? . runnerFailed ) {
notices . push ( ` [sandbox: the sandbox runner itself failed under ${ sandbox . mode } mode — the command did not run; this is a sandbox problem, not a command failure] ` )
} else if ( sandbox ? . denied ) {
2026-07-16 23:31:51 +08:00
notices . push ( sandboxDenialMarker ( sandbox . mode ) )
2026-07-15 13:38:17 +08:00
if ( escalationModes . length > 0 ) {
2026-07-16 23:31:51 +08:00
notices . push ( escalationHintMarker ( 'command' ) )
2026-07-15 13:38:17 +08:00
}
}
if ( notices . length === 0 ) return read . delta
return ` ${ read . delta } ${ read . delta . length > 0 && ! read . delta . endsWith ( '\n' ) ? '\n' : '' } ${ notices . join ( '\n' ) } `
}
2026-07-14 03:45:22 +08:00
/**
2026-07-29 10:00:29 +08:00
* The exit status recovered from a rendered result, with the output body that
* status was split off from.
*/
export type ParsedExitStatus =
& { body : string }
& ( { exitCode : number } | { signal : string } )
/**
* Split a rendered {@link renderResult} string into its output body and the
* structured exit status — the inverse of the status markers it appends. A
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
* absent both means a clean exit 0.
*
* The consumed marker is removed from `body` because a terminal presentation
* shows the exit status as its own pill: leaving the marker in the output would
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
* pill shows, so they stay in the body.
2026-07-14 03:45:22 +08:00
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
* Requiring a leading newline and the end of the string keeps ordinary output
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
2026-07-29 10:00:29 +08:00
* @returns the marker-free body plus the recovered terminal exit code or signal.
2026-07-14 03:45:22 +08:00
*/
2026-07-29 10:00:29 +08:00
export function parseExitStatus ( text : string ) : ParsedExitStatus {
2026-07-14 03:45:22 +08:00
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/ . exec ( text )
2026-07-29 10:00:29 +08:00
if ( signal ? . [ 1 ] !== undefined ) return { body : text.slice ( 0 , signal . index ) , signal : signal [ 1 ] }
2026-07-14 03:45:22 +08:00
const exit = /\n\[exit code: (\d+)\]$/ . exec ( text )
2026-07-29 10:00:29 +08:00
if ( exit ? . [ 1 ] !== undefined ) return { body : text.slice ( 0 , exit . index ) , exitCode : Number ( exit [ 1 ] ) }
return { body : text , exitCode : 0 }
2026-07-14 03:45:22 +08:00
}