Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,48 @@ def test_write_json_returns_false_on_broken_pipe(monkeypatch):
assert server.write_json({"ok": True}) is False


def test_tui_verbose_tool_details_fail_closed_when_redaction_fails(monkeypatch):
redact_module = types.ModuleType("agent.redact")

def fail_redaction(*_args, **_kwargs):
raise RuntimeError("redaction unavailable")

redact_module.redact_sensitive_text = fail_redaction
monkeypatch.setitem(sys.modules, "agent.redact", redact_module)

assert server._redact_tui_verbose_text("api_key=secret") == ""
assert server._tool_args_text({"api_key": "secret"}) == ""
assert server._tool_result_text("token=secret") == ""


def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch):
redact_module = types.ModuleType("agent.redact")

def fail_redaction(*_args, **_kwargs):
raise RuntimeError("redaction unavailable")

redact_module.redact_sensitive_text = fail_redaction
monkeypatch.setitem(sys.modules, "agent.redact", redact_module)

events: list[tuple[str, str, dict]] = []
monkeypatch.setattr(
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
)
monkeypatch.setitem(
server._sessions,
"redaction-test",
{"tool_progress_mode": "verbose", "tool_started_at": {}},
)

server._on_tool_start("redaction-test", "tool-1", "terminal", {"command": "pwd"})
server._on_tool_complete("redaction-test", "tool-1", "terminal", {"command": "pwd"}, "done")

assert events[0][0] == "tool.start"
assert events[1][0] == "tool.complete"
assert "args_text" not in events[0][2]
assert "result_text" not in events[1][2]


def test_dispatch_rejects_non_object_request():
resp = server.dispatch([])

Expand Down
61 changes: 54 additions & 7 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,10 @@ def _session_tool_progress_mode(sid: str) -> str:
return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all")


def _session_verbose(sid: str) -> bool:
return _session_tool_progress_mode(sid) == "verbose"


def _tool_progress_enabled(sid: str) -> bool:
return _session_tool_progress_mode(sid) != "off"

Expand Down Expand Up @@ -1492,6 +1496,33 @@ def _tool_ctx(name: str, args: dict) -> str:
return ""


def _redact_tui_verbose_text(text: str) -> str:
try:
from agent.redact import redact_sensitive_text

return redact_sensitive_text(str(text), force=True)
except Exception:
return ""


def _tool_args_text(args: dict) -> str:
try:
raw = json.dumps(args or {}, indent=2, ensure_ascii=False, default=str)
except Exception:
raw = str(args or {})
return _redact_tui_verbose_text(raw)


def _tool_result_text(result: object) -> str:
try:
from agent.tool_dispatch_helpers import _multimodal_text_summary

raw = _multimodal_text_summary(result)
except Exception:
raw = str(result)
return _redact_tui_verbose_text(raw)
Comment thread
OutThisLife marked this conversation as resolved.
Resolved


def _fmt_tool_duration(seconds: float | None) -> str:
if seconds is None:
return ""
Expand Down Expand Up @@ -1553,13 +1584,18 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
pass
session.setdefault("tool_started_at", {})[tool_call_id] = time.time()
if _tool_progress_enabled(sid):
payload = {
"tool_id": tool_call_id,
"name": name,
"context": _tool_ctx(name, args),
}
if _session_verbose(sid):
args_text = _tool_args_text(args)
if args_text:
payload["args_text"] = args_text
# tool.complete is the source of truth for todos (full list from the
# tool result). args.todos here may be a partial merge update.
_emit(
"tool.start",
sid,
{"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
)
_emit("tool.start", sid, payload)


def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
Expand All @@ -1576,6 +1612,10 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
summary = _tool_summary(name, result, duration_s)
if summary:
payload["summary"] = summary
if _session_verbose(sid):
result_text = _tool_result_text(result)
if result_text:
payload["result_text"] = result_text
if name == "todo":
try:
data = json.loads(result)
Expand Down Expand Up @@ -1615,7 +1655,10 @@ def _on_tool_progress(
_emit("tool.progress", sid, {"name": name, "preview": preview or ""})
return
if event_type == "reasoning.available" and preview:
_emit("reasoning.available", sid, {"text": str(preview)})
payload = {"text": str(preview)}
if _session_verbose(sid):
payload["verbose"] = True
_emit("reasoning.available", sid, payload)
return
if event_type.startswith("subagent."):
payload = {
Expand Down Expand Up @@ -1691,7 +1734,11 @@ def _agent_cbs(sid: str) -> dict:
"tool_gen_callback": lambda name: _tool_progress_enabled(sid)
and _emit("tool.generating", sid, {"name": name}),
"thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}),
"reasoning_callback": lambda text: _emit("reasoning.delta", sid, {"text": text}),
"reasoning_callback": lambda text: _emit(
"reasoning.delta",
sid,
{"text": text, **({"verbose": True} if _session_verbose(sid) else {})},
),
"status_callback": lambda kind, text=None: _status_update(
sid, str(kind), None if text is None else str(text)
),
Expand Down
24 changes: 23 additions & 1 deletion ui-tui/src/__tests__/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
boundedLiveRenderText,
buildToolTrailLine,
buildVerboseToolTrailLine,
edgePreview,
estimateRows,
estimateTokensRough,
Expand All @@ -12,8 +13,8 @@ import {
lastCotTrailIndex,
parseToolTrailResultLine,
pasteTokenLabel,
sanitizeAnsiForRender,
sameToolTrailGroup,
sanitizeAnsiForRender,
splitToolDuration,
stripAnsi,
thinkingPreview
Expand All @@ -37,6 +38,27 @@ describe('buildToolTrailLine', () => {
})
})

describe('buildVerboseToolTrailLine', () => {
it('preserves multiline args and result details', () => {
const line = buildVerboseToolTrailLine(
'terminal',
'npm test',
false,
1.25,
'{\n "cmd": "npm test"\n}',
'first line\nsecond :: line'
)

expect(line).toContain('Args:\n{')
expect(line).toContain('Result:\nfirst line\nsecond :: line')
expect(parseToolTrailResultLine(line)).toEqual({
call: 'Terminal("npm test") (1.3s)',
detail: 'Args:\n{\n "cmd": "npm test"\n}\nResult:\nfirst line\nsecond :: line',
mark: '✓'
})
})
})

describe('lastCotTrailIndex', () => {
it('finds last non-result line', () => {
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
Expand Down
14 changes: 10 additions & 4 deletions ui-tui/src/app/createGatewayEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,13 +491,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:

case 'reasoning.delta':
if (ev.payload?.text) {
turnController.recordReasoningDelta(ev.payload.text)
turnController.recordReasoningDelta(ev.payload.text, Boolean(ev.payload.verbose))
}

Comment thread
OutThisLife marked this conversation as resolved.
Resolved
return

case 'reasoning.available':
turnController.recordReasoningAvailable(String(ev.payload?.text ?? ''))
turnController.recordReasoningAvailable(String(ev.payload?.text ?? ''), Boolean(ev.payload?.verbose))

return

Expand All @@ -517,7 +517,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:

case 'tool.start':
turnController.recordTodos(ev.payload.todos)
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
turnController.recordToolStart(
ev.payload.tool_id,
ev.payload.name ?? 'tool',
ev.payload.context ?? '',
ev.payload.args_text ? stripAnsi(String(ev.payload.args_text)) : undefined
)

return
case 'tool.complete': {
Expand All @@ -539,7 +544,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
ev.payload.error,
ev.payload.summary,
ev.payload.duration_s,
ev.payload.todos
ev.payload.todos,
ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined
)
Comment thread
OutThisLife marked this conversation as resolved.
Resolved
}

Expand Down
45 changes: 29 additions & 16 deletions ui-tui/src/app/turnController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
buildVerboseToolTrailLine,
estimateTokensRough,
isTransientTrailLine,
sameToolTrailGroup,
Expand Down Expand Up @@ -542,8 +543,8 @@ class TurnController {
}
}

recordReasoningAvailable(text: string) {
if (this.interrupted || !getUiState().showReasoning) {
recordReasoningAvailable(text: string, force = false) {
if (this.interrupted || (!force && !getUiState().showReasoning)) {
return
}

Expand All @@ -560,8 +561,8 @@ class TurnController {
this.pulseReasoningStreaming()
}

recordReasoningDelta(text: string) {
if (this.interrupted || !getUiState().showReasoning) {
recordReasoningDelta(text: string, force = false) {
if (this.interrupted || (!force && !getUiState().showReasoning)) {
return
}

Expand All @@ -587,14 +588,15 @@ class TurnController {
error?: string,
summary?: string,
duration?: number,
todos?: unknown
todos?: unknown,
resultText?: string
) {
if (this.interrupted) {
return
}

this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration)
const line = this.completeTool(toolId, fallbackName, error, summary, duration, resultText)

this.pendingSegmentTools = [...this.pendingSegmentTools, line]
this.flushPendingToolsIntoLastSegment()
Expand All @@ -617,19 +619,30 @@ class TurnController {
this.publishToolState()
}

private completeTool(toolId: string, fallbackName?: string, error?: string, summary?: string, duration?: number) {
private completeTool(
toolId: string,
fallbackName?: string,
error?: string,
summary?: string,
duration?: number,
resultText?: string
) {
const done = this.activeTools.find(tool => tool.id === toolId)
const name = done?.name ?? fallbackName ?? 'tool'
const label = toolTrailLabel(name)
const fallbackDuration = done?.startedAt ? (Date.now() - done.startedAt) / 1000 : undefined

const line = buildToolTrailLine(
name,
done?.context || '',
Boolean(error),
error || summary || '',
duration ?? fallbackDuration
)
const line =
done?.verboseArgs || resultText
? buildVerboseToolTrailLine(
name,
done?.context || '',
Boolean(error),
duration ?? fallbackDuration,
done?.verboseArgs,
error || resultText || summary || ''
)
: buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '', duration ?? fallbackDuration)

this.activeTools = this.activeTools.filter(tool => tool.id !== toolId)

Expand Down Expand Up @@ -675,7 +688,7 @@ class TurnController {
}, STREAM_BATCH_MS)
}

recordToolStart(toolId: string, name: string, context: string) {
recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) {
if (this.interrupted) {
return
}
Expand All @@ -688,7 +701,7 @@ class TurnController {
const sample = `${name} ${context}`.trim()

this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0
this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }]
this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now(), verboseArgs }]

patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools })
}
Expand Down
11 changes: 10 additions & 1 deletion ui-tui/src/components/thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,16 @@ export const ToolTrail = memo(function ToolTrail({
color: t.color.text,
key: tool.id,
label,
details: [],
details: tool.verboseArgs
? [
{
color: t.color.muted,
content: `Args:\n${boundedLiveRenderText(tool.verboseArgs)}`,
dimColor: true,
key: `${tool.id}-args`
}
]
: [],
content: (
<>
<Spinner color={t.color.accent} variant="tool" /> {label}
Expand Down
5 changes: 3 additions & 2 deletions ui-tui/src/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,11 +477,11 @@ export type GatewayEvent =
type: 'gateway.start_timeout'
}
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload?: { text?: string; verbose?: boolean }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
| {
payload: { context?: string; name?: string; tool_id: string; todos?: unknown[] }
payload: { args_text?: string; context?: string; name?: string; tool_id: string; todos?: unknown[] }
session_id?: string
type: 'tool.start'
}
Expand All @@ -491,6 +491,7 @@ export type GatewayEvent =
error?: string
inline_diff?: string
name?: string
result_text?: string
summary?: string
tool_id: string
todos?: unknown[]
Expand Down
Loading
Loading