11 KiB
spec, created, updated, status
| spec | created | updated | status |
|---|---|---|---|
| docs/specs/ui/requirements/acp-shell-command-output.md | 2026-07-14 | 2026-07-16 | done |
Implementation Plan: ACP Shell Command Output
Overview
Tasks 01-04 shipped provider normalization, durable bounded output, exit semantics, and the first expandable transcript UI. This follow-up keeps that persistence contract but removes output bodies from normal browser message projections, adds a session-scoped snapshot endpoint, and changes the command row to an always-visible wrapped command plus an output-only disclosure that fetches and polls on demand. ADR-0042 records the transport boundary.
Completed Foundation
- Tasks 01-02 normalize provider output into
metadata.normalized.shell_exec.output, persist live updates, bound each text field to 256 KiB, and preserve nullable exit status. - Tasks 03-04 render and verify output and exit semantics on desktop and mobile.
- The new work does not change ACP parsing, the database schema, the 256 KiB bound, or standalone terminal behavior.
Backend
Shared Client Message Projection
Files:
apps/backend/internal/task/models/models.goapps/backend/internal/task/models/message_shell_output.go(new)apps/backend/internal/task/models/message_shell_output_test.go(new)apps/backend/internal/task/service/service_events.goapps/backend/internal/task/service/service_events_test.goapps/backend/internal/backendapp/helpers_test.go
Changes:
- Add one structured metadata helper that recognizes both live typed
*streams.NormalizedPayloadvalues and generic maps decoded from persisted JSON. ProjectMessageMetadatauses copy-on-write along the normalized shell path, removes shellstdout/stderr, and addshas_output,stdout_bytes, andstderr_byteswhile retaining nullableexit_codeandtruncated.- Route
Message.ToAPI()andpublishMessageEventthrough that helper. This covers REST lists, WebSocketmessage.list, task-route boot hydration, andsession.message.added/session.message.updatedwithout mutating repository models. - Keep non-shell metadata byte-for-byte equivalent after JSON serialization and leave internal repository/service consumers on the full persisted form.
On-Demand Output Snapshot
Files:
apps/backend/internal/task/models/message_shell_output.goapps/backend/internal/task/handlers/message_handlers.goapps/backend/internal/task/handlers/message_handlers_test.goapps/backend/internal/task/dto/dto.go
Changes:
- Add
GET /api/v1/task-sessions/:session_id/messages/:message_id/shell-outputbeside the existing message routes. - Load through
Service.GetMessage, require the row to belong to the path session, and extract a normalized shell snapshot withmessage_id, toolstatus,updated_at, and the persisted output fields. - Return
404for absent, cross-session, or non-shell messages; return200with an empty output object for a valid shell command that has not emitted output. - Return the existing bounded snapshot as a whole value. Do not add range, delta, or streaming behavior.
Frontend
Output API and Polling Hook
Files:
apps/web/lib/api/domains/session-api.tsapps/web/hooks/domains/session/use-shell-command-output.ts(new)apps/web/hooks/domains/session/use-shell-command-output.test.ts(new)apps/web/components/task/chat/types.ts
Changes:
- Split the browser type into
ShellExecOutputSummaryfor message metadata andShellCommandOutputSnapshotfor the endpoint response. - Add
fetchShellCommandOutput(sessionId, messageId, { signal })using the shared API client. - The hook fetches immediately only when open. A running snapshot schedules the next request after the previous one settles, using a one-second base interval and failure backoff capped at five seconds.
- Stop and abort on collapse or unmount. A terminal endpoint response stops recurring polling; a terminal status transition from the message projection aborts the active poll, fetches one final snapshot, and then stops. Ignore stale responses and retain the latest successful snapshot across transient errors.
Command Row and Output Disclosure
Files:
apps/web/components/task/chat/messages/tool-execute-message.tsxapps/web/components/task/chat/messages/shell-output-disclosure.tsx(new, if extraction is needed to keep component/function limits)apps/web/components/task/chat/messages/tool-execute-message.test.tsx
Changes:
- Render the full normalized command, falling back to message content, with
whitespace-pre-wrapand word breaking rather than truncation. Keep the working directory and status visible with it. - Remove command duplication from expanded content and remove running auto-expansion.
- Add a separate, keyboard-accessible output disclosure collapsed by default for running and terminal commands. Its compact label uses summary byte counts/truncation without requiring the body.
- Mount fetched stdout/stderr only while open. Render loading, empty, unavailable-with-retry, truncation, and exit states without shifting or overflowing desktop/mobile chat.
No Zustand slice is added; the snapshot is ephemeral disclosure state and normal session messages retain only summaries.
Tests
- What: typed live metadata and persisted map metadata project to identical summaries, omit both bodies, preserve nullable exit/truncation, compute retained UTF-8 byte counts, and do not mutate input.
File:
apps/backend/internal/task/models/message_shell_output_test.go. How: table-driven unit tests for stdout-only, explicit stderr, empty running output, unknown exit, non-shell metadata, and multibyte text. - What: normal WebSocket notifications use the summary projection and task-route boot messages contain no transcript body.
File:
apps/backend/internal/task/service/service_events_test.goandapps/backend/internal/backendapp/helpers_test.go. How: publish/boot focused tests with a unique large-output sentinel and assert summary fields are present while the sentinel is absent from serialized payloads. - What: the snapshot route returns full valid output and enforces message/session/type scoping.
File:
apps/backend/internal/task/handlers/message_handlers_test.go. How: handler tests for200populated,200empty-running, missing404, cross-session404, and non-shell404. - What: opening triggers one fetch, terminal output does not poll, running output polls without overlap, failure backoff is capped, and collapse/unmount aborts stale work.
File:
apps/web/hooks/domains/session/use-shell-command-output.test.ts. How: Vitest fake timers and deferred API promises. - What: full commands wrap, both running and completed output start collapsed, no body is read from summary metadata, and disclosure states render exact status semantics.
File:
apps/web/components/task/chat/messages/tool-execute-message.test.tsx. How: React Testing Library with endpoint hook/API mocks and long-command fixtures.
E2E Tests
- Scenario: GIVEN completed shell messages with large persisted transcripts, WHEN desktop chat opens, THEN full commands are visible, output is collapsed, and no shell-output request occurs until a disclosure is expanded; expansion performs the request and shows the transcript/result.
File:
apps/web/e2e/tests/chat/tool-execute-output.spec.ts. - Scenario: GIVEN an expanded running shell command, WHEN sequential snapshots add output and become terminal, THEN the transcript refreshes and request count stops after terminal.
File:
apps/web/e2e/tests/chat/tool-execute-output.spec.tsusing a route-controlled response sequence. - Scenario: GIVEN a long command and large output on a narrow viewport, WHEN mobile chat is collapsed and then expanded, THEN command wrapping, disclosure controls, output, and exit status stay within the page width without overlap.
File:
apps/web/e2e/tests/chat/mobile-tool-execute-output.spec.ts.
Implementation Waves
Completed waves:
- task-01-backend-normalization (
done) - task-02-acp-live-updates (
done) - task-03-frontend-command-row (
done) - task-04-e2e-and-verification (
done)
Follow-up waves:
Wave 4:
Wave 5:
Wave 6:
- task-07-lazy-output-e2e (
in_progress)
Dependency graph:
01-backend-normalization --> 02-acp-live-updates ----> 04-e2e-and-verification
`--> 03-frontend-command-row --'
04-e2e-and-verification --> 05-backend-output-projection --> 06-frontend-output-disclosure --> 07-lazy-output-e2e
Tasks 05-07 are sequential because Task 06 consumes Task 05's exact summary/snapshot contract and Task 07 verifies the integrated transport and UI.
Verification
make -C apps/backend fmt
make -C apps/backend test
make -C apps/backend lint
cd apps && pnpm --filter @kandev/web test -- hooks/domains/session/use-shell-command-output.test.ts components/task/chat/messages/tool-execute-message.test.tsx
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm e2e:run --project chromium tests/chat/tool-execute-output.spec.ts
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/chat/mobile-tool-execute-output.spec.ts
make fmt
make typecheck
make test
make lint
Risks
- Message metadata exists in typed form before persistence and generic-map form after JSON decoding. The projection and extraction helper must cover both or live notifications and reloads will diverge.
Message.ToAPI()andpublishMessageEventare separate delivery paths today. Tests must prevent a future path from leaking full bodies even though boot and list routes already reuseToAPI().- Polling whole snapshots is intentionally simple, but a user can expand several running commands. One in-flight request per open disclosure and cleanup on collapse/unmount are required to keep concurrency bounded.
- Full output still lives inside the message metadata column. This iteration reduces network/browser cost, not database storage or metadata-row decode cost.
- Output snapshots may race a normal terminal message update. Either signal must stop polling, and stale responses must not replace a newer snapshot.
Open Questions
None. The endpoint shape, summary fields, polling bounds, storage choice, and failure behavior are fixed by the spec and ADR.