feat(chat): lazy load shell command output (#1739)
This commit is contained in:
@@ -608,6 +608,67 @@ func TestBootRouteDataTaskDetailIncludesTaskPageData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootTaskDetailMessagesProjectShellOutput(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
workspaces, err := harness.taskSvc.ListWorkspaces(ctx)
|
||||
if err != nil || len(workspaces) == 0 {
|
||||
t.Fatalf("ListWorkspaces: %v", err)
|
||||
}
|
||||
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
Title: "Shell output projection",
|
||||
IsEphemeral: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
||||
if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "shell-session", TaskID: task.ID, State: models.TaskSessionStateWaitingForInput,
|
||||
StartedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession: %v", err)
|
||||
}
|
||||
if err := harness.taskRepo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "shell-turn", TaskID: task.ID, TaskSessionID: "shell-session",
|
||||
StartedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTurn: %v", err)
|
||||
}
|
||||
if err := harness.taskRepo.CreateMessage(ctx, &models.Message{
|
||||
ID: "shell-message", TaskID: task.ID, TaskSessionID: "shell-session", TurnID: "shell-turn",
|
||||
AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Content: "make test",
|
||||
Metadata: map[string]any{
|
||||
"status": "completed",
|
||||
"normalized": map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{
|
||||
"command": "make test",
|
||||
"output": map[string]any{"stdout": "boot-output-sentinel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage: %v", err)
|
||||
}
|
||||
|
||||
state := map[string]any{}
|
||||
builder := bootStateBuilder{p: routeParams{taskSvc: harness.taskSvc}}
|
||||
builder.addTaskDetailActiveTaskState(ctx, state, taskdto.FromTask(task), "shell-session")
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal state: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "boot-output-sentinel") {
|
||||
t.Fatal("boot message payload leaked shell output")
|
||||
}
|
||||
if !strings.Contains(string(raw), `"stdout_bytes":20`) {
|
||||
t.Fatalf("boot message payload missing shell output summary: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootRouteDataTasksIncludesFirstPageRows(t *testing.T) {
|
||||
taskSvc, workflowSvc := newBootStateTestServices(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -76,6 +76,14 @@ type RepositoryScriptDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ShellOutputSnapshotResponse is the on-demand full output for one shell message.
|
||||
type ShellOutputSnapshotResponse struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Output models.ShellExecOutputSnapshot `json:"output"`
|
||||
}
|
||||
|
||||
type ExecutorDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -59,6 +60,32 @@ func (h *MessageHandlers) registerHTTP(router *gin.Engine) {
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/agent-sessions/:id/messages", h.httpListMessages)
|
||||
api.GET("/task-sessions/:id/messages", h.httpListMessages) // Alias for SSR compatibility
|
||||
api.GET("/task-sessions/:id/messages/:message_id/shell-output", h.httpGetShellOutput)
|
||||
}
|
||||
|
||||
func (h *MessageHandlers) httpGetShellOutput(c *gin.Context) {
|
||||
message, err := h.service.GetMessage(c.Request.Context(), c.Param("message_id"))
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
h.logger.Error("failed to get shell output message", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get shell output"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
output, ok := models.ExtractShellExecOutput(message.Metadata)
|
||||
if !ok || message.TaskSessionID != c.Param("id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
status, _ := message.Metadata["status"].(string)
|
||||
c.JSON(http.StatusOK, dto.ShellOutputSnapshotResponse{
|
||||
MessageID: message.ID,
|
||||
Status: status,
|
||||
UpdatedAt: message.UpdatedAt,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MessageHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
|
||||
@@ -3,11 +3,15 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -19,6 +23,111 @@ import (
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
type shellOutputMessageRepo struct {
|
||||
mockRepository
|
||||
messages map[string]*models.Message
|
||||
errors map[string]error
|
||||
}
|
||||
|
||||
func (r *shellOutputMessageRepo) GetMessage(_ context.Context, id string) (*models.Message, error) {
|
||||
if err := r.errors[id]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message, ok := r.messages[id]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func TestHTTPShellOutputSnapshot(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
||||
exitCode := float64(4)
|
||||
populated := map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{
|
||||
"command": "make test",
|
||||
"output": map[string]any{
|
||||
"exit_code": exitCode,
|
||||
"stdout": "test output",
|
||||
"stderr": "test error",
|
||||
"truncated": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
empty := map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{"command": "make test"},
|
||||
}
|
||||
repo := &shellOutputMessageRepo{messages: map[string]*models.Message{
|
||||
"populated": {ID: "populated", TaskSessionID: "session-1", Type: models.MessageTypeToolExecute, UpdatedAt: now, Metadata: map[string]any{"status": "completed", "normalized": populated}},
|
||||
"empty": {ID: "empty", TaskSessionID: "session-1", Type: models.MessageTypeToolExecute, UpdatedAt: now, Metadata: map[string]any{"status": "running", "normalized": empty}},
|
||||
"other": {ID: "other", TaskSessionID: "session-2", Type: models.MessageTypeToolExecute, UpdatedAt: now, Metadata: map[string]any{"status": "completed", "normalized": populated}},
|
||||
"non-shell": {ID: "non-shell", TaskSessionID: "session-1", Type: models.MessageTypeToolRead, UpdatedAt: now, Metadata: map[string]any{"status": "completed", "normalized": map[string]any{"kind": "read_file", "read_file": map[string]any{"file_path": "README.md"}}}},
|
||||
}, errors: map[string]error{"broken": errors.New("database unavailable")}}
|
||||
router := shellOutputTestRouter(t, repo)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
assertBody func(*testing.T, map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "populated",
|
||||
path: "/api/v1/task-sessions/session-1/messages/populated/shell-output",
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body map[string]any) {
|
||||
require.Equal(t, "populated", body["message_id"])
|
||||
require.Equal(t, "completed", body["status"])
|
||||
require.Equal(t, now.Format(time.RFC3339), body["updated_at"])
|
||||
output := body["output"].(map[string]any)
|
||||
require.Equal(t, "test output", output["stdout"])
|
||||
require.Equal(t, "test error", output["stderr"])
|
||||
require.Equal(t, exitCode, output["exit_code"])
|
||||
require.Equal(t, true, output["truncated"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty running shell",
|
||||
path: "/api/v1/task-sessions/session-1/messages/empty/shell-output",
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body map[string]any) {
|
||||
require.Equal(t, "running", body["status"])
|
||||
require.Empty(t, body["output"].(map[string]any))
|
||||
},
|
||||
},
|
||||
{name: "missing", path: "/api/v1/task-sessions/session-1/messages/missing/shell-output", wantStatus: http.StatusNotFound},
|
||||
{name: "cross session", path: "/api/v1/task-sessions/session-1/messages/other/shell-output", wantStatus: http.StatusNotFound},
|
||||
{name: "non shell", path: "/api/v1/task-sessions/session-1/messages/non-shell/shell-output", wantStatus: http.StatusNotFound},
|
||||
{name: "repository failure", path: "/api/v1/task-sessions/session-1/messages/broken/shell-output", wantStatus: http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
require.Equal(t, tt.wantStatus, response.Code)
|
||||
if tt.assertBody != nil {
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
|
||||
tt.assertBody(t, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func shellOutputTestRouter(t *testing.T, repo *shellOutputMessageRepo) *gin.Engine {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
require.NoError(t, err)
|
||||
svc := service.NewService(service.Repos{Messages: repo}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
router := gin.New()
|
||||
NewMessageHandlers(svc, nil, log).registerHTTP(router)
|
||||
return router
|
||||
}
|
||||
|
||||
// sessionStateSequencer is a mock repository that returns a sequence of session states.
|
||||
// Each call to GetTaskSession returns the next state in the sequence.
|
||||
type sessionStateSequencer struct {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package models
|
||||
|
||||
import "github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
|
||||
// ShellExecOutputSnapshot is the full bounded output stored on a shell message.
|
||||
type ShellExecOutputSnapshot struct {
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectMessageMetadata removes shell output bodies from client message payloads.
|
||||
// It copies the normalized shell path so repository-owned metadata is never mutated.
|
||||
func ProjectMessageMetadata(metadata map[string]any) map[string]any {
|
||||
output, ok := shellOutputFromMetadata(metadata)
|
||||
if !ok {
|
||||
return metadata
|
||||
}
|
||||
normalized, ok := projectNormalizedShell(metadata["normalized"], shellOutputSummary(output))
|
||||
if !ok {
|
||||
return metadata
|
||||
}
|
||||
projected := copyMetadata(metadata)
|
||||
projected["normalized"] = normalized
|
||||
return projected
|
||||
}
|
||||
|
||||
// ExtractShellExecOutput returns the full stored snapshot for normalized shell metadata.
|
||||
func ExtractShellExecOutput(metadata map[string]any) (ShellExecOutputSnapshot, bool) {
|
||||
return shellOutputFromMetadata(metadata)
|
||||
}
|
||||
|
||||
func shellOutputFromMetadata(metadata map[string]any) (ShellExecOutputSnapshot, bool) {
|
||||
if metadata == nil {
|
||||
return ShellExecOutputSnapshot{}, false
|
||||
}
|
||||
raw, ok := metadata["normalized"]
|
||||
if !ok || raw == nil {
|
||||
return ShellExecOutputSnapshot{}, false
|
||||
}
|
||||
switch normalized := raw.(type) {
|
||||
case *streams.NormalizedPayload:
|
||||
if normalized == nil || normalized.ShellExec() == nil {
|
||||
return ShellExecOutputSnapshot{}, false
|
||||
}
|
||||
return shellOutputFromTyped(normalized.ShellExec().Output), true
|
||||
case map[string]any:
|
||||
shell, shellOK := normalized["shell_exec"].(map[string]any)
|
||||
if !shellOK {
|
||||
return ShellExecOutputSnapshot{}, false
|
||||
}
|
||||
return shellOutputFromMap(shell["output"]), true
|
||||
default:
|
||||
return ShellExecOutputSnapshot{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func shellOutputFromTyped(output *streams.ShellExecOutput) ShellExecOutputSnapshot {
|
||||
if output == nil {
|
||||
return ShellExecOutputSnapshot{}
|
||||
}
|
||||
return ShellExecOutputSnapshot{
|
||||
ExitCode: output.ExitCode,
|
||||
Stdout: output.Stdout,
|
||||
Stderr: output.Stderr,
|
||||
Truncated: output.Truncated,
|
||||
}
|
||||
}
|
||||
|
||||
func shellOutputFromMap(raw any) ShellExecOutputSnapshot {
|
||||
output, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ShellExecOutputSnapshot{}
|
||||
}
|
||||
result := ShellExecOutputSnapshot{
|
||||
Stdout: StringFromAny(output["stdout"]),
|
||||
Stderr: StringFromAny(output["stderr"]),
|
||||
Truncated: boolFromAny(output["truncated"]),
|
||||
}
|
||||
if exitCode, ok := intFromAny(output["exit_code"]); ok {
|
||||
result.ExitCode = &exitCode
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shellOutputSummary(output ShellExecOutputSnapshot) map[string]any {
|
||||
summary := map[string]any{
|
||||
"has_output": output.Stdout != "" || output.Stderr != "",
|
||||
"stdout_bytes": len(output.Stdout),
|
||||
"stderr_bytes": len(output.Stderr),
|
||||
"truncated": output.Truncated,
|
||||
}
|
||||
if output.ExitCode != nil {
|
||||
summary["exit_code"] = *output.ExitCode
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func projectNormalizedShell(raw any, summary map[string]any) (map[string]any, bool) {
|
||||
switch normalized := raw.(type) {
|
||||
case *streams.NormalizedPayload:
|
||||
if normalized == nil || normalized.ShellExec() == nil {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": string(streams.ToolKindShellExec),
|
||||
"shell_exec": projectTypedShell(normalized.ShellExec(), summary),
|
||||
}, true
|
||||
case map[string]any:
|
||||
shell, ok := normalized["shell_exec"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
projectedShell := copyMetadata(shell)
|
||||
projectedShell["output"] = summary
|
||||
projected := copyMetadata(normalized)
|
||||
projected["shell_exec"] = projectedShell
|
||||
return projected, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func projectTypedShell(shell *streams.ShellExecPayload, summary map[string]any) map[string]any {
|
||||
projected := map[string]any{
|
||||
"command": shell.Command,
|
||||
"output": summary,
|
||||
}
|
||||
if shell.WorkDir != "" {
|
||||
projected["work_dir"] = shell.WorkDir
|
||||
}
|
||||
if shell.Description != "" {
|
||||
projected["description"] = shell.Description
|
||||
}
|
||||
if shell.Timeout != 0 {
|
||||
projected["timeout"] = shell.Timeout
|
||||
}
|
||||
if shell.Background {
|
||||
projected["background"] = true
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
func intFromAny(raw any) (int, bool) {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return value, true
|
||||
case int32:
|
||||
return int(value), true
|
||||
case int64:
|
||||
return int(value), true
|
||||
case float64:
|
||||
return int(value), value == float64(int(value))
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func boolFromAny(raw any) bool {
|
||||
value, _ := raw.(bool)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMessageToAPIProjectsTypedShellOutputWithoutMutation(t *testing.T) {
|
||||
exitCode := 7
|
||||
normalized := streams.NewShellExec("go test ./...", "/workspace", "", 0, false)
|
||||
normalized.ShellExec().Output = &streams.ShellExecOutput{
|
||||
ExitCode: &exitCode,
|
||||
Stdout: "pass ✓\n",
|
||||
Stderr: "warning\n",
|
||||
Truncated: true,
|
||||
}
|
||||
message := &Message{Metadata: map[string]any{
|
||||
"status": "completed",
|
||||
"normalized": normalized,
|
||||
}}
|
||||
|
||||
projected := jsonMetadata(t, message.ToAPI().Metadata)
|
||||
output := projected["normalized"].(map[string]any)["shell_exec"].(map[string]any)["output"].(map[string]any)
|
||||
|
||||
require.NotContains(t, output, "stdout")
|
||||
require.NotContains(t, output, "stderr")
|
||||
require.Equal(t, true, output["has_output"])
|
||||
require.Equal(t, float64(len("pass ✓\n")), output["stdout_bytes"])
|
||||
require.Equal(t, float64(len("warning\n")), output["stderr_bytes"])
|
||||
require.Equal(t, float64(exitCode), output["exit_code"])
|
||||
require.Equal(t, true, output["truncated"])
|
||||
require.Equal(t, "pass ✓\n", normalized.ShellExec().Output.Stdout)
|
||||
require.Equal(t, "warning\n", normalized.ShellExec().Output.Stderr)
|
||||
}
|
||||
|
||||
func TestMessageToAPIProjectsPersistedShellOutputWithoutMutation(t *testing.T) {
|
||||
output := map[string]any{
|
||||
"stdout": "persisted stdout",
|
||||
"stderr": "",
|
||||
"truncated": false,
|
||||
}
|
||||
message := &Message{Metadata: map[string]any{
|
||||
"status": "running",
|
||||
"normalized": map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{
|
||||
"command": "make test",
|
||||
"output": output,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
projected := jsonMetadata(t, message.ToAPI().Metadata)
|
||||
projectedOutput := projected["normalized"].(map[string]any)["shell_exec"].(map[string]any)["output"].(map[string]any)
|
||||
|
||||
require.NotContains(t, projectedOutput, "stdout")
|
||||
require.NotContains(t, projectedOutput, "stderr")
|
||||
require.Equal(t, true, projectedOutput["has_output"])
|
||||
require.Equal(t, float64(len("persisted stdout")), projectedOutput["stdout_bytes"])
|
||||
require.Equal(t, float64(0), projectedOutput["stderr_bytes"])
|
||||
require.Equal(t, false, projectedOutput["truncated"])
|
||||
require.Equal(t, "persisted stdout", output["stdout"])
|
||||
originalShell := message.Metadata["normalized"].(map[string]any)["shell_exec"].(map[string]any)
|
||||
require.Equal(t, output, originalShell["output"])
|
||||
}
|
||||
|
||||
func TestProjectMessageMetadataMatchesTypedAndPersistedShellOutput(t *testing.T) {
|
||||
exitCode := 3
|
||||
typed := streams.NewShellExec("printf ✓", "/workspace", "print", 30, true)
|
||||
typed.ShellExec().Output = &streams.ShellExecOutput{ExitCode: &exitCode, Stdout: "✓", Truncated: true}
|
||||
persisted := map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{
|
||||
"command": "printf ✓", "work_dir": "/workspace", "description": "print",
|
||||
"timeout": float64(30), "background": true,
|
||||
"output": map[string]any{"exit_code": float64(3), "stdout": "✓", "truncated": true},
|
||||
},
|
||||
}
|
||||
|
||||
typedJSON, err := json.Marshal(ProjectMessageMetadata(map[string]any{"normalized": typed})["normalized"])
|
||||
require.NoError(t, err)
|
||||
persistedJSON, err := json.Marshal(ProjectMessageMetadata(map[string]any{"normalized": persisted})["normalized"])
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(typedJSON), string(persistedJSON))
|
||||
}
|
||||
|
||||
func jsonMetadata(t *testing.T, metadata map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
return decoded
|
||||
}
|
||||
@@ -576,7 +576,7 @@ func (m *Message) ToAPI() *v1.Message {
|
||||
messageType = string(MessageTypeMessage)
|
||||
}
|
||||
hasHidden := sysprompt.HasSystemContent(m.Content)
|
||||
meta := m.Metadata
|
||||
meta := ProjectMessageMetadata(m.Metadata)
|
||||
if hasHidden {
|
||||
if meta == nil {
|
||||
meta = make(map[string]interface{})
|
||||
|
||||
@@ -403,7 +403,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes
|
||||
data["raw_content"] = message.Content
|
||||
}
|
||||
|
||||
meta := message.Metadata
|
||||
meta := models.ProjectMessageMetadata(message.Metadata)
|
||||
if hasHidden {
|
||||
if meta == nil {
|
||||
meta = make(map[string]interface{})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublishMessageEventProjectsShellOutput(t *testing.T) {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
require.NoError(t, err)
|
||||
eventBus := NewMockEventBus()
|
||||
svc := NewService(Repos{}, eventBus, log, RepositoryDiscoveryConfig{})
|
||||
normalized := streams.NewShellExec("make test", "/workspace", "", 0, false)
|
||||
normalized.ShellExec().Output = &streams.ShellExecOutput{Stdout: "live-output-sentinel", Stderr: "live-error"}
|
||||
message := &models.Message{ID: "message-1", TaskSessionID: "session-1", Metadata: map[string]any{
|
||||
"status": "running",
|
||||
"normalized": normalized,
|
||||
}}
|
||||
|
||||
svc.publishMessageEvent(context.Background(), events.MessageUpdated, message)
|
||||
|
||||
require.Len(t, eventBus.GetPublishedEvents(), 1)
|
||||
raw, err := json.Marshal(eventBus.GetPublishedEvents()[0].Data)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "live-output-sentinel")
|
||||
require.NotContains(t, string(raw), "live-error")
|
||||
require.Contains(t, string(raw), `"stdout_bytes":20`)
|
||||
require.Equal(t, "live-output-sentinel", normalized.ShellExec().Output.Stdout)
|
||||
}
|
||||
|
||||
func TestPublishMessageEventProjectsPersistedShellOutput(t *testing.T) {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
require.NoError(t, err)
|
||||
eventBus := NewMockEventBus()
|
||||
svc := NewService(Repos{}, eventBus, log, RepositoryDiscoveryConfig{})
|
||||
output := map[string]any{"stdout": "persisted-event-sentinel"}
|
||||
message := &models.Message{ID: "message-1", TaskSessionID: "session-1", Metadata: map[string]any{
|
||||
"status": "running",
|
||||
"normalized": map[string]any{
|
||||
"kind": "shell_exec",
|
||||
"shell_exec": map[string]any{"command": "make test", "output": output},
|
||||
},
|
||||
}}
|
||||
|
||||
svc.publishMessageEvent(context.Background(), events.MessageAdded, message)
|
||||
|
||||
require.Len(t, eventBus.GetPublishedEvents(), 1)
|
||||
raw, err := json.Marshal(eventBus.GetPublishedEvents()[0].Data)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "persisted-event-sentinel")
|
||||
require.Contains(t, string(raw), `"stdout_bytes":24`)
|
||||
require.Equal(t, "persisted-event-sentinel", output["stdout"])
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
|
||||
import { useShellCommandOutput } from "@/hooks/domains/session/use-shell-command-output";
|
||||
import type { ShellCommandOutput, ShellCommandOutputSnapshot } from "@/lib/api/domains/session-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status";
|
||||
import type { ShellExecOutputSummary } from "../types";
|
||||
|
||||
type ShellOutputDisclosureProps = {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
messageStatus?: string;
|
||||
summary?: ShellExecOutputSummary;
|
||||
};
|
||||
|
||||
function formatOutputSize(summary: ShellExecOutputSummary | undefined) {
|
||||
const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0);
|
||||
if (bytes === 0) return "Output";
|
||||
if (bytes < 1024) return `Output · ${bytes} B`;
|
||||
return `Output · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`;
|
||||
}
|
||||
|
||||
function OutputTranscript({ output }: { output: ShellCommandOutput }) {
|
||||
if (!output.stdout && !output.stderr) return null;
|
||||
return (
|
||||
<div className="space-y-2" data-testid="tool-execute-output">
|
||||
{output.stdout && (
|
||||
<pre className="max-h-[200px] overflow-auto rounded bg-muted/30 p-2 whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-xs">
|
||||
{output.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{output.stderr && (
|
||||
<pre className="max-h-[200px] overflow-auto rounded bg-red-500/10 p-2 whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-xs text-red-600 dark:text-red-400">
|
||||
{output.stderr}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultDetails({ snapshot }: { snapshot: ShellCommandOutputSnapshot }) {
|
||||
const { output, status } = snapshot;
|
||||
const normalizedStatus = normalizeToolCallStatus(status);
|
||||
if (!isTerminalToolCallStatus(status) && !output.truncated) return null;
|
||||
const knownExit = typeof output.exit_code === "number";
|
||||
const failed = normalizedStatus === "error" || (knownExit && output.exit_code !== 0);
|
||||
const succeeded = normalizedStatus === "complete" && knownExit && output.exit_code === 0;
|
||||
let exitClass = "text-muted-foreground";
|
||||
if (knownExit && failed) exitClass = "text-red-600 dark:text-red-400";
|
||||
if (succeeded) exitClass = "text-green-600 dark:text-green-400";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border/40 pt-2 text-xs text-muted-foreground">
|
||||
{output.truncated && <span>Output truncated</span>}
|
||||
{isTerminalToolCallStatus(status) && (
|
||||
<span className={cn("font-mono", exitClass)}>
|
||||
{knownExit ? `Exit code ${output.exit_code}` : "Exit code unavailable"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisclosureContent({
|
||||
snapshot,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
messageStatus,
|
||||
}: {
|
||||
snapshot: ShellCommandOutputSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
retry: () => void;
|
||||
messageStatus?: string;
|
||||
}) {
|
||||
const hasTranscript = Boolean(snapshot?.output.stdout || snapshot?.output.stderr);
|
||||
const emptyLabel =
|
||||
normalizeToolCallStatus(snapshot?.status) === "running" &&
|
||||
!isTerminalToolCallStatus(messageStatus)
|
||||
? "No command output yet."
|
||||
: "No command output.";
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2 border-l-2 border-border/30 pl-3 pt-1">
|
||||
{isLoading && !snapshot && (
|
||||
<p className="text-xs text-muted-foreground">Loading command output...</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-destructive">
|
||||
<span>Command output unavailable.</span>
|
||||
<Button variant="ghost" size="xs" className="cursor-pointer" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{snapshot && !hasTranscript && !error && (
|
||||
<p className="text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
)}
|
||||
{snapshot && <OutputTranscript output={snapshot.output} />}
|
||||
{snapshot && <ResultDetails snapshot={snapshot} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShellOutputDisclosure({
|
||||
sessionId,
|
||||
messageId,
|
||||
messageStatus,
|
||||
summary,
|
||||
}: ShellOutputDisclosureProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const output = useShellCommandOutput({ sessionId, messageId, isOpen, messageStatus });
|
||||
const label = formatOutputSize(summary);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 h-7 max-w-full cursor-pointer gap-1 px-2 text-muted-foreground"
|
||||
aria-label={`${isOpen ? "Hide" : "Show"} command output`}
|
||||
>
|
||||
{isOpen ? <IconChevronDown aria-hidden /> : <IconChevronRight aria-hidden />}
|
||||
<span className="truncate">{label}</span>
|
||||
{summary?.truncated && (
|
||||
<span className="text-amber-600 dark:text-amber-400">truncated</span>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{isOpen && <DisclosureContent {...output} messageStatus={messageStatus} />}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,52 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { sessionId, taskId, type Message } from "@/lib/types/http";
|
||||
import type { UseShellCommandOutputResult } from "@/hooks/domains/session/use-shell-command-output";
|
||||
import { ToolExecuteMessage } from "./tool-execute-message";
|
||||
|
||||
const useShellOutputMock = vi.hoisted(() => vi.fn());
|
||||
const runningMessageID = "message-running";
|
||||
const outputUpdatedAt = "2026-07-16T12:00:02Z";
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-shell-command-output", () => ({
|
||||
useShellCommandOutput: useShellOutputMock,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type ShellOutput = {
|
||||
beforeEach(() => {
|
||||
useShellOutputMock.mockReset();
|
||||
useShellOutputMock.mockReturnValue(hookResult());
|
||||
});
|
||||
|
||||
type ShellOutputSummary = {
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
has_output?: boolean;
|
||||
stdout_bytes?: number;
|
||||
stderr_bytes?: number;
|
||||
truncated?: boolean;
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
function executeMessage(
|
||||
status: "pending" | "running" | "in_progress" | "complete" | "error" | "cancelled",
|
||||
output?: ShellOutput,
|
||||
output: ShellOutputSummary = {},
|
||||
command = "printf normalized-command",
|
||||
): Message {
|
||||
return {
|
||||
id: `message-${status}`,
|
||||
session_id: sessionId("session-1"),
|
||||
task_id: taskId("task-1"),
|
||||
author_type: "agent",
|
||||
content: "printf command-output",
|
||||
content: "fallback message content",
|
||||
type: "tool_execute",
|
||||
created_at: "2026-07-14T12:00:00Z",
|
||||
created_at: "2026-07-16T12:00:00Z",
|
||||
metadata: {
|
||||
status,
|
||||
normalized: {
|
||||
shell_exec: {
|
||||
command: "printf command-output",
|
||||
work_dir: "/workspace",
|
||||
command,
|
||||
work_dir: "/workspace/with/a/long/path",
|
||||
output,
|
||||
},
|
||||
},
|
||||
@@ -37,91 +54,188 @@ function executeMessage(
|
||||
};
|
||||
}
|
||||
|
||||
function expandCommand() {
|
||||
fireEvent.click(screen.getAllByText("printf command-output")[0]);
|
||||
function hookResult(
|
||||
overrides: Partial<UseShellCommandOutputResult> = {},
|
||||
): UseShellCommandOutputResult {
|
||||
return {
|
||||
snapshot: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ToolExecuteMessage command result", () => {
|
||||
it("shows output and an exact successful exit code", () => {
|
||||
function openOutput() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /show command output/i }));
|
||||
}
|
||||
|
||||
describe("ToolExecuteMessage command row", () => {
|
||||
it("keeps the normalized command and working directory visible while output is collapsed", () => {
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("complete", { exit_code: 0, stdout: "command output\n" })}
|
||||
comment={executeMessage("running", {
|
||||
has_output: true,
|
||||
stdout_bytes: 2048,
|
||||
stderr_bytes: 0,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Command succeeded")).toBeTruthy();
|
||||
expandCommand();
|
||||
expect(screen.getByText("command output")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code 0")).toBeTruthy();
|
||||
const command = screen.getByTestId("tool-execute-command");
|
||||
expect(command.textContent).toBe("printf normalized-command");
|
||||
expect(command.className).toContain("whitespace-pre-wrap");
|
||||
expect(screen.getByText("/workspace/with/a/long/path")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /show command output/i }).getAttribute("aria-expanded"),
|
||||
).toBe("false");
|
||||
expect(screen.queryByTestId("tool-execute-output")).toBeNull();
|
||||
expect(useShellOutputMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ isOpen: false, sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a known nonzero exit as failure", () => {
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("complete", { exit_code: 7, stderr: "failed output\n" })}
|
||||
/>,
|
||||
);
|
||||
it("falls back to message content when the normalized command is absent", () => {
|
||||
render(<ToolExecuteMessage comment={executeMessage("complete", {}, "")} />);
|
||||
|
||||
expect(screen.getByLabelText("Command failed")).toBeTruthy();
|
||||
expandCommand();
|
||||
expect(screen.getByText("failed output")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code 7")).toBeTruthy();
|
||||
expect(screen.getByTestId("tool-execute-command").textContent).toBe("fallback message content");
|
||||
});
|
||||
|
||||
it("keeps an unavailable exit code neutral", () => {
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("complete", { stdout: "result without status" })}
|
||||
/>,
|
||||
it("renders loading and empty snapshot states only after opening", () => {
|
||||
useShellOutputMock.mockReturnValue(hookResult({ isLoading: true }));
|
||||
const view = render(<ToolExecuteMessage comment={executeMessage("running")} />);
|
||||
|
||||
expect(screen.queryByText("Loading command output...")).toBeNull();
|
||||
openOutput();
|
||||
expect(screen.getByText("Loading command output...")).toBeTruthy();
|
||||
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: runningMessageID,
|
||||
status: "running",
|
||||
updated_at: "2026-07-16T12:00:01Z",
|
||||
output: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Command succeeded")).toBeNull();
|
||||
expect(screen.queryByLabelText("Command failed")).toBeNull();
|
||||
expandCommand();
|
||||
expect(screen.getByText("Exit code unavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("auto-expands live output without a terminal exit label", () => {
|
||||
render(
|
||||
<ToolExecuteMessage comment={executeMessage("running", { stdout: "partial output\n" })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Command running")).toBeTruthy();
|
||||
expect(screen.getByText("partial output")).toBeTruthy();
|
||||
view.rerender(<ToolExecuteMessage comment={executeMessage("running")} />);
|
||||
expect(screen.getByText("No command output yet.")).toBeTruthy();
|
||||
expect(screen.queryByText(/Exit code/)).toBeNull();
|
||||
});
|
||||
|
||||
it("treats ACP in_progress output as live", () => {
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("in_progress", { stdout: "ACP partial output\n" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Command running")).toBeTruthy();
|
||||
expect(screen.getByText("ACP partial output")).toBeTruthy();
|
||||
expect(screen.queryByText(/Exit code/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows truncation and unknown exit state together", () => {
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("complete", { stdout: "latest output", truncated: true })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expandCommand();
|
||||
expect(screen.getByText("Output truncated")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code unavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows terminal details for a cancelled command", () => {
|
||||
render(
|
||||
<ToolExecuteMessage comment={executeMessage("cancelled", { stdout: "cancelled output" })} />,
|
||||
);
|
||||
|
||||
expandCommand();
|
||||
expect(screen.getByText("cancelled output")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code unavailable")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolExecuteMessage output states", () => {
|
||||
it("renders stdout, stderr, truncation, and a known nonzero exit from the snapshot", () => {
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: "message-complete",
|
||||
status: "failed",
|
||||
updated_at: outputUpdatedAt,
|
||||
output: {
|
||||
stdout: "standard output",
|
||||
stderr: "standard error",
|
||||
truncated: true,
|
||||
exit_code: 7,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<ToolExecuteMessage comment={executeMessage("complete", { exit_code: 7 })} />);
|
||||
|
||||
openOutput();
|
||||
expect(screen.getByText("standard output")).toBeTruthy();
|
||||
expect(screen.getByText("standard error")).toBeTruthy();
|
||||
expect(screen.getByText("Output truncated")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code 7").className).toContain("text-red");
|
||||
});
|
||||
|
||||
it("renders unknown terminal exit neutrally and never reads a transcript body from summary metadata", () => {
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: "message-cancelled",
|
||||
status: "cancelled",
|
||||
updated_at: outputUpdatedAt,
|
||||
output: { stdout: "snapshot transcript" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<ToolExecuteMessage
|
||||
comment={executeMessage("cancelled", {
|
||||
has_output: true,
|
||||
stdout_bytes: 999,
|
||||
stdout: "forbidden summary transcript",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
openOutput();
|
||||
expect(screen.getByText("snapshot transcript")).toBeTruthy();
|
||||
expect(screen.queryByText("forbidden summary transcript")).toBeNull();
|
||||
expect(screen.getByText("Exit code unavailable").className).toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
it("keeps the latest snapshot visible on an error and retries on command", () => {
|
||||
const retry = vi.fn();
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: runningMessageID,
|
||||
status: "running",
|
||||
updated_at: outputUpdatedAt,
|
||||
output: { stdout: "retained transcript" },
|
||||
},
|
||||
error: new Error("network unavailable"),
|
||||
retry,
|
||||
}),
|
||||
);
|
||||
render(<ToolExecuteMessage comment={executeMessage("running")} />);
|
||||
|
||||
openOutput();
|
||||
expect(screen.getByText("retained transcript")).toBeTruthy();
|
||||
expect(screen.getByText("Command output unavailable.")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows only the unavailable state when an empty snapshot is followed by an error", () => {
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: runningMessageID,
|
||||
status: "running",
|
||||
updated_at: outputUpdatedAt,
|
||||
output: {},
|
||||
},
|
||||
error: new Error("network unavailable"),
|
||||
}),
|
||||
);
|
||||
render(<ToolExecuteMessage comment={executeMessage("running")} />);
|
||||
|
||||
openOutput();
|
||||
expect(screen.getByText("Command output unavailable.")).toBeTruthy();
|
||||
expect(screen.queryByText("No command output yet.")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolExecuteMessage cancelled result", () => {
|
||||
it("renders a cancelled zero exit neutrally", () => {
|
||||
useShellOutputMock.mockReturnValue(
|
||||
hookResult({
|
||||
snapshot: {
|
||||
message_id: "message-cancelled",
|
||||
status: "cancelled",
|
||||
updated_at: outputUpdatedAt,
|
||||
output: { exit_code: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<ToolExecuteMessage comment={executeMessage("cancelled", { exit_code: 0 })} />);
|
||||
|
||||
openOutput();
|
||||
expect(screen.getByText("Exit code 0").className).toContain("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { IconCheck, IconX, IconTerminal } from "@tabler/icons-react";
|
||||
import { IconCheck, IconTerminal, IconX } from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { transformPathsInText } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { ExpandableRow } from "./expandable-row";
|
||||
import { ShellOutputDisclosure } from "./shell-output-disclosure";
|
||||
import { normalizeToolCallStatus } from "./tool-status";
|
||||
import { useExpandState } from "./use-expand-state";
|
||||
import type { ShellExecOutput, ToolCallMetadata } from "../types";
|
||||
import type { ToolCallMetadata } from "../types";
|
||||
|
||||
type ToolExecuteMessageProps = {
|
||||
comment: Message;
|
||||
@@ -22,24 +21,17 @@ function ExecuteStatusIcon({
|
||||
status: string | undefined;
|
||||
exitCode: number | undefined;
|
||||
}) {
|
||||
if (status === "complete") {
|
||||
if (exitCode === 0) {
|
||||
return (
|
||||
<span className="shrink-0" aria-label="Command succeeded">
|
||||
<IconCheck aria-hidden className="h-3.5 w-3.5 text-green-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof exitCode === "number") {
|
||||
return (
|
||||
<span className="shrink-0" aria-label="Command failed">
|
||||
<IconX aria-hidden className="h-3.5 w-3.5 text-red-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
if (status === "complete" && exitCode === 0) {
|
||||
return (
|
||||
<span className="shrink-0" aria-label="Command succeeded">
|
||||
<IconCheck aria-hidden className="h-3.5 w-3.5 text-green-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
if (
|
||||
status === "error" ||
|
||||
(status === "complete" && typeof exitCode === "number" && exitCode !== 0)
|
||||
) {
|
||||
return (
|
||||
<span className="shrink-0" aria-label="Command failed">
|
||||
<IconX aria-hidden className="h-3.5 w-3.5 text-red-500" />
|
||||
@@ -56,133 +48,51 @@ function ExecuteStatusIcon({
|
||||
return null;
|
||||
}
|
||||
|
||||
type ExecuteOutputProps = {
|
||||
displayCommand: string;
|
||||
displayWorkDir: string | null;
|
||||
workDir: string | undefined;
|
||||
output: ShellExecOutput | undefined;
|
||||
status: ToolCallMetadata["status"];
|
||||
};
|
||||
|
||||
function TerminalOutput({ output }: { output: ShellExecOutput | undefined }) {
|
||||
if (!output?.stdout && !output?.stderr) return null;
|
||||
return (
|
||||
<div className="space-y-2" data-testid="tool-execute-output">
|
||||
{output.stdout && (
|
||||
<pre className="text-xs bg-muted/30 rounded p-2 overflow-x-auto whitespace-pre-wrap break-words max-h-[200px]">
|
||||
{output.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{output.stderr && (
|
||||
<pre className="text-xs bg-red-500/10 text-red-600 dark:text-red-400 rounded max-h-[200px] p-2 overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{output.stderr}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecuteResultDetails({
|
||||
output,
|
||||
status,
|
||||
}: {
|
||||
output: ShellExecOutput | undefined;
|
||||
status: ToolCallMetadata["status"];
|
||||
}) {
|
||||
const isTerminal = status === "complete" || status === "error" || status === "cancelled";
|
||||
if (!isTerminal && !output?.truncated) return null;
|
||||
const exitLabel =
|
||||
typeof output?.exit_code === "number"
|
||||
? `Exit code ${output.exit_code}`
|
||||
: "Exit code unavailable";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border/40 pt-2 text-xs text-muted-foreground"
|
||||
data-testid="tool-execute-result-details"
|
||||
>
|
||||
{output?.truncated && <span>Output truncated</span>}
|
||||
{isTerminal && <span className="font-mono">{exitLabel}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecuteOutputContent({
|
||||
displayCommand,
|
||||
displayWorkDir,
|
||||
workDir,
|
||||
output,
|
||||
status,
|
||||
}: ExecuteOutputProps) {
|
||||
return (
|
||||
<div className="min-w-0 pl-4 border-l-2 border-border/30 space-y-2">
|
||||
<pre className="text-xs bg-muted/30 rounded p-2 whitespace-pre-wrap break-all font-mono">
|
||||
{displayCommand}
|
||||
</pre>
|
||||
{displayWorkDir && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="opacity-60">cwd:</span>{" "}
|
||||
<span className="font-mono" title={workDir}>
|
||||
{displayWorkDir}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TerminalOutput output={output} />
|
||||
<ExecuteResultDetails output={output} status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseExecuteMetadata(comment: Message) {
|
||||
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
||||
const status = normalizeToolCallStatus(metadata?.status);
|
||||
const shellExec = metadata?.normalized?.shell_exec;
|
||||
const output = shellExec?.output;
|
||||
const workDir = shellExec?.work_dir;
|
||||
return { status, output, workDir };
|
||||
}
|
||||
|
||||
function CommandHeader({ displayCommand }: { displayCommand: string }) {
|
||||
return (
|
||||
<span
|
||||
className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1 text-left"
|
||||
data-testid="tool-execute-command"
|
||||
>
|
||||
{displayCommand}
|
||||
</span>
|
||||
);
|
||||
return { status, shellExec };
|
||||
}
|
||||
|
||||
export const ToolExecuteMessage = memo(function ToolExecuteMessage({
|
||||
comment,
|
||||
worktreePath,
|
||||
}: ToolExecuteMessageProps) {
|
||||
const { status, output, workDir } = parseExecuteMetadata(comment);
|
||||
const autoExpanded = status === "running";
|
||||
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
|
||||
const displayCommand = transformPathsInText(comment.content, worktreePath);
|
||||
const { status, shellExec } = parseExecuteMetadata(comment);
|
||||
const command = shellExec?.command || comment.content;
|
||||
const displayCommand = transformPathsInText(command, worktreePath);
|
||||
const workDir = shellExec?.work_dir;
|
||||
const displayWorkDir = workDir ? transformPathsInText(workDir, worktreePath) : null;
|
||||
|
||||
return (
|
||||
<ExpandableRow
|
||||
icon={<IconTerminal className="h-4 w-4 text-muted-foreground" />}
|
||||
header={
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<CommandHeader displayCommand={displayCommand} />
|
||||
<ExecuteStatusIcon status={status} exitCode={output?.exit_code} />
|
||||
<div className="flex w-full min-w-0 items-start gap-3 px-2 py-1 -mx-2">
|
||||
<IconTerminal className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<pre
|
||||
className="min-w-0 flex-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-left font-mono text-xs text-muted-foreground"
|
||||
data-testid="tool-execute-command"
|
||||
>
|
||||
{displayCommand}
|
||||
</pre>
|
||||
<ExecuteStatusIcon status={status} exitCode={shellExec?.output?.exit_code} />
|
||||
</div>
|
||||
}
|
||||
hasExpandableContent
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
<ExecuteOutputContent
|
||||
displayCommand={displayCommand}
|
||||
displayWorkDir={displayWorkDir}
|
||||
workDir={workDir}
|
||||
output={output}
|
||||
status={status}
|
||||
/>
|
||||
</ExpandableRow>
|
||||
{displayWorkDir && (
|
||||
<div className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-xs text-muted-foreground">
|
||||
<span className="opacity-60">cwd:</span>{" "}
|
||||
<span className="font-mono" title={workDir}>
|
||||
{displayWorkDir}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ShellOutputDisclosure
|
||||
sessionId={comment.session_id}
|
||||
messageId={comment.id}
|
||||
messageStatus={status}
|
||||
summary={shellExec?.output}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { ToolCallMetadata } from "../types";
|
||||
|
||||
export type NormalizedToolCallStatus = Exclude<ToolCallMetadata["status"], "in_progress">;
|
||||
|
||||
export function normalizeToolCallStatus(
|
||||
status: ToolCallMetadata["status"],
|
||||
): NormalizedToolCallStatus {
|
||||
return status === "in_progress" ? "running" : status;
|
||||
}
|
||||
export {
|
||||
isTerminalToolCallStatus,
|
||||
normalizeToolCallStatus,
|
||||
type NormalizedToolCallStatus,
|
||||
} from "@/lib/utils/tool-call-status";
|
||||
|
||||
@@ -3,7 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
|
||||
import { TurnGroupMessage } from "./turn-group-message";
|
||||
|
||||
function toolExecute(id: string, command = "gh pr checks"): Message {
|
||||
function toolExecute(
|
||||
id: string,
|
||||
output: Record<string, unknown> = {},
|
||||
command = "gh pr checks",
|
||||
): Message {
|
||||
return {
|
||||
id,
|
||||
session_id: toSessionId("s1"),
|
||||
@@ -18,7 +22,7 @@ function toolExecute(id: string, command = "gh pr checks"): Message {
|
||||
normalized: {
|
||||
shell_exec: {
|
||||
command,
|
||||
output: { exit_code: 0, stdout: "1" },
|
||||
output: { exit_code: 0, ...output },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,7 +39,7 @@ function cancelledToolExecute(id: string): Message {
|
||||
normalized: {
|
||||
shell_exec: {
|
||||
command: "cancelled-command",
|
||||
output: { stdout: "cancelled-output" },
|
||||
output: { has_output: true, stdout_bytes: 16, stderr_bytes: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -65,6 +69,29 @@ describe("TurnGroupMessage repeated tool compaction", () => {
|
||||
expect(html).toContain("4 repeated identical terminal commands hidden");
|
||||
});
|
||||
|
||||
it("does not summarize commands when only their output bodies distinguish them", () => {
|
||||
const messages = Array.from({ length: 6 }, (_, i) =>
|
||||
toolExecute(`tool-${i + 1}`, { has_output: true, stdout_bytes: 8, stderr_bytes: 0 }),
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<TurnGroupMessage
|
||||
group={{
|
||||
type: "turn_group",
|
||||
id: "turn-group-output-tool-1",
|
||||
turnId: "turn-1",
|
||||
messages,
|
||||
}}
|
||||
sessionId="s1"
|
||||
permissionsByToolCallId={new Map()}
|
||||
isLastGroup
|
||||
isTurnActive
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-testid="repeated-tool-summary"');
|
||||
});
|
||||
|
||||
it("treats a cancelled tool as terminal", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TurnGroupMessage
|
||||
|
||||
@@ -164,8 +164,10 @@ type ShellExecSummary = {
|
||||
command: string;
|
||||
workDir: string;
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
hasOutput: boolean;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type ShellExecPayload = NonNullable<NonNullable<ToolCallMetadata["normalized"]>["shell_exec"]>;
|
||||
@@ -182,18 +184,33 @@ function isZeroExitCode(shellExec: ShellExecPayload): boolean {
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
function hasProjectedShellOutput(output: ShellExecPayload["output"]): boolean {
|
||||
return (
|
||||
Boolean(output?.has_output) ||
|
||||
(output?.stdout_bytes ?? 0) > 0 ||
|
||||
(output?.stderr_bytes ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function createShellExecSummary(message: Message, shellExec: ShellExecPayload): ShellExecSummary {
|
||||
const output = shellExec.output;
|
||||
return {
|
||||
command: shellExec.command ?? message.content,
|
||||
workDir: shellExec.work_dir ?? "",
|
||||
exitCode: 0,
|
||||
hasOutput: output?.has_output ?? false,
|
||||
stdoutBytes: output?.stdout_bytes ?? 0,
|
||||
stderrBytes: output?.stderr_bytes ?? 0,
|
||||
truncated: output?.truncated ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function readShellExecSummary(message: Message): ShellExecSummary | null {
|
||||
const shellExec = getCompleteShellExec(message);
|
||||
if (!shellExec) return null;
|
||||
if (!isZeroExitCode(shellExec)) return null;
|
||||
const output = shellExec.output;
|
||||
return {
|
||||
command: shellExec?.command ?? message.content,
|
||||
workDir: shellExec?.work_dir ?? "",
|
||||
exitCode: 0,
|
||||
stdout: output?.stdout ?? "",
|
||||
stderr: output?.stderr ?? "",
|
||||
};
|
||||
if (hasProjectedShellOutput(shellExec.output)) return null;
|
||||
return createShellExecSummary(message, shellExec);
|
||||
}
|
||||
|
||||
function repeatFingerprint(message: Message): string | null {
|
||||
|
||||
@@ -95,10 +95,11 @@ export type ModifyFilePayload = {
|
||||
mutations?: FileMutation[];
|
||||
};
|
||||
|
||||
export type ShellExecOutput = {
|
||||
export type ShellExecOutputSummary = {
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
has_output?: boolean;
|
||||
stdout_bytes?: number;
|
||||
stderr_bytes?: number;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
@@ -108,7 +109,7 @@ export type ShellExecPayload = {
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
background?: boolean;
|
||||
output?: ShellExecOutput;
|
||||
output?: ShellExecOutputSummary;
|
||||
};
|
||||
|
||||
export type HttpRequestPayload = {
|
||||
@@ -134,7 +135,16 @@ export type ToolCallMetadata = {
|
||||
parent_tool_call_id?: string; // For subagent nesting
|
||||
tool_name?: string;
|
||||
title?: string;
|
||||
status?: "pending" | "running" | "in_progress" | "complete" | "error" | "cancelled";
|
||||
status?:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "in_progress"
|
||||
| "complete"
|
||||
| "completed"
|
||||
| "success"
|
||||
| "error"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
normalized?: NormalizedPayload;
|
||||
|
||||
@@ -10,7 +10,7 @@ function repeatedToolMetadata(i: number) {
|
||||
normalized: {
|
||||
shell_exec: {
|
||||
command: "gh pr checks",
|
||||
output: { exit_code: 0, stdout: "1" },
|
||||
output: { exit_code: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function expectInsideViewport(
|
||||
testPage: import("@playwright/test").Page,
|
||||
locator: import("@playwright/test").Locator,
|
||||
) {
|
||||
const viewportWidth = testPage.viewportSize()?.width ?? 0;
|
||||
const box = await locator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth + 1);
|
||||
}
|
||||
|
||||
test.describe("mobile: shell command output", () => {
|
||||
test("keeps truncated output and exit status inside the viewport", async ({
|
||||
test("keeps a long command and lazy output inside the viewport", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -18,7 +29,8 @@ test.describe("mobile: shell command output", () => {
|
||||
state: "IDLE",
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
});
|
||||
const command = "printf mobile-command-output";
|
||||
const command = `printf ${"mobile-command-segment-".repeat(28)}`;
|
||||
const outputPrefix = "latest output";
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "tool_execute",
|
||||
content: command,
|
||||
@@ -31,33 +43,51 @@ test.describe("mobile: shell command output", () => {
|
||||
work_dir: "/workspace/a/very/long/path/that/must/not/expand/the/page",
|
||||
output: {
|
||||
exit_code: 9,
|
||||
stdout: `latest output ${"unbroken-output-".repeat(80)}`,
|
||||
stdout: `${outputPrefix} ${"unbroken-output-".repeat(80)}`,
|
||||
truncated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const shellOutputRequests: string[] = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (request.url().endsWith("/shell-output")) shellOutputRequests.push(request.url());
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const chat = session.activeChat();
|
||||
const commandRow = chat.getByTestId("tool-execute-command").filter({ hasText: command });
|
||||
const disclosure = chat.getByRole("button", { name: "Show command output" });
|
||||
|
||||
await expect(commandRow).toBeVisible({ timeout: 15_000 });
|
||||
await commandRow.click();
|
||||
await expect(commandRow).toHaveText(command);
|
||||
await expect(chat.getByTestId("tool-execute-output")).toHaveCount(0);
|
||||
await expect(chat.getByText(outputPrefix)).toHaveCount(0);
|
||||
expect(shellOutputRequests).toHaveLength(0);
|
||||
expect(await commandRow.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
await expectInsideViewport(testPage, commandRow);
|
||||
await expectInsideViewport(testPage, disclosure);
|
||||
|
||||
const responsePromise = testPage.waitForResponse(
|
||||
(response) => response.url().endsWith("/shell-output") && response.status() === 200,
|
||||
);
|
||||
await disclosure.click();
|
||||
await responsePromise;
|
||||
|
||||
const output = chat.getByTestId("tool-execute-output");
|
||||
const details = chat.getByTestId("tool-execute-result-details");
|
||||
await expect(output).toContainText("latest output");
|
||||
await expect(details).toContainText("Output truncated");
|
||||
await expect(details).toContainText("Exit code 9");
|
||||
const exitStatus = chat.getByText("Exit code 9");
|
||||
await expect(output).toContainText(outputPrefix);
|
||||
await expect(chat.getByText("Output truncated")).toBeVisible();
|
||||
await expect(exitStatus).toBeVisible();
|
||||
expect(shellOutputRequests).toHaveLength(1);
|
||||
|
||||
const viewportWidth = testPage.viewportSize()?.width ?? 0;
|
||||
const outputBox = await output.boundingBox();
|
||||
expect(outputBox).not.toBeNull();
|
||||
expect(outputBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(outputBox!.x + outputBox!.width).toBeLessThanOrEqual(viewportWidth + 1);
|
||||
await expectInsideViewport(testPage, output);
|
||||
await expectInsideViewport(testPage, exitStatus);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
|
||||
@@ -11,7 +11,7 @@ type ShellOutput = {
|
||||
type SeedShellMessageOptions = {
|
||||
title: string;
|
||||
command: string;
|
||||
status: "complete" | "error";
|
||||
status: "running" | "complete" | "error";
|
||||
output: ShellOutput;
|
||||
};
|
||||
|
||||
@@ -46,7 +46,116 @@ async function seedShellMessage(
|
||||
}
|
||||
|
||||
test.describe("shell command output", () => {
|
||||
test("shows persisted success, failure, and unknown exit results", async ({
|
||||
test("keeps full commands visible and fetches completed output only after expansion", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const command = `printf '%s\\n' ${"complete-command-segment-".repeat(48)}`;
|
||||
const transcript = "persisted transcript loaded on demand";
|
||||
const taskId = await seedShellMessage(apiClient, seedData, {
|
||||
title: "Lazy Completed Command",
|
||||
command,
|
||||
status: "error",
|
||||
output: { exit_code: 7, stdout: `${transcript}\n` },
|
||||
});
|
||||
const shellOutputRequests: string[] = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (request.url().endsWith("/shell-output")) shellOutputRequests.push(request.url());
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const chat = session.activeChat();
|
||||
const commandRow = chat.getByTestId("tool-execute-command").filter({ hasText: command });
|
||||
const disclosure = chat.getByRole("button", { name: "Show command output" });
|
||||
|
||||
await expect(commandRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(commandRow).toHaveText(command);
|
||||
await expect(disclosure).toBeVisible();
|
||||
await expect(chat.getByTestId("tool-execute-output")).toHaveCount(0);
|
||||
await expect(chat.getByText(transcript)).toHaveCount(0);
|
||||
expect(shellOutputRequests).toHaveLength(0);
|
||||
|
||||
const wrapsAcrossLines = await commandRow.evaluate((element) => {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight);
|
||||
return element.getBoundingClientRect().height > lineHeight * 2;
|
||||
});
|
||||
expect(wrapsAcrossLines).toBe(true);
|
||||
|
||||
const responsePromise = testPage.waitForResponse(
|
||||
(response) => response.url().endsWith("/shell-output") && response.status() === 200,
|
||||
);
|
||||
await disclosure.click();
|
||||
await responsePromise;
|
||||
|
||||
await expect(chat.getByText(transcript)).toBeVisible();
|
||||
await expect(chat.getByText("Exit code 7")).toBeVisible();
|
||||
await expect(chat.getByLabel("Command failed")).toBeVisible();
|
||||
expect(shellOutputRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("refreshes expanded running output and stops after the terminal snapshot", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const command = "printf controlled-running-command";
|
||||
const taskId = await seedShellMessage(apiClient, seedData, {
|
||||
title: "Controlled Running Command",
|
||||
command,
|
||||
status: "running",
|
||||
output: { stdout: "persisted output is replaced by routed snapshots\n" },
|
||||
});
|
||||
const snapshots = [
|
||||
{
|
||||
message_id: "controlled-running-message",
|
||||
status: "running",
|
||||
updated_at: "2026-07-16T12:00:00Z",
|
||||
output: { stdout: "partial running transcript\n" },
|
||||
},
|
||||
{
|
||||
message_id: "controlled-running-message",
|
||||
status: "complete",
|
||||
updated_at: "2026-07-16T12:00:01Z",
|
||||
output: { exit_code: 0, stdout: "final running transcript\n" },
|
||||
},
|
||||
];
|
||||
let requestCount = 0;
|
||||
await testPage.route("**/api/v1/task-sessions/*/messages/*/shell-output", async (route) => {
|
||||
const snapshot = snapshots[Math.min(requestCount, snapshots.length - 1)];
|
||||
requestCount += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(snapshot),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const chat = session.activeChat();
|
||||
const disclosure = chat.getByRole("button", { name: "Show command output" });
|
||||
|
||||
await expect(chat.getByTestId("tool-execute-command").filter({ hasText: command })).toBeVisible(
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
expect(requestCount).toBe(0);
|
||||
await disclosure.click();
|
||||
|
||||
await expect(chat.getByText("partial running transcript")).toBeVisible();
|
||||
await expect.poll(() => requestCount, { timeout: 4_000 }).toBe(2);
|
||||
await expect(chat.getByText("final running transcript")).toBeVisible();
|
||||
await expect(chat.getByText("Exit code 0")).toBeVisible();
|
||||
await testPage.waitForTimeout(1_250);
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
test("preserves successful and unknown completed result semantics", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -55,25 +164,14 @@ test.describe("shell command output", () => {
|
||||
{
|
||||
title: "Successful Command",
|
||||
command: "printf successful-command",
|
||||
status: "complete" as const,
|
||||
output: { exit_code: 0, stdout: "successful output\n" },
|
||||
statusLabel: "Command succeeded",
|
||||
exitLabel: "Exit code 0",
|
||||
outputText: "successful output",
|
||||
},
|
||||
{
|
||||
title: "Failed Command",
|
||||
command: "printf failed-command",
|
||||
status: "error" as const,
|
||||
output: { exit_code: 7, stderr: "failed output\n" },
|
||||
statusLabel: "Command failed",
|
||||
exitLabel: "Exit code 7",
|
||||
outputText: "failed output",
|
||||
},
|
||||
{
|
||||
title: "Unknown Exit Command",
|
||||
command: "printf unknown-exit-command",
|
||||
status: "complete" as const,
|
||||
output: { stdout: "unknown exit output\n" },
|
||||
statusLabel: null,
|
||||
exitLabel: "Exit code unavailable",
|
||||
@@ -85,7 +183,7 @@ test.describe("shell command output", () => {
|
||||
const taskId = await seedShellMessage(apiClient, seedData, {
|
||||
title: scenario.title,
|
||||
command: scenario.command,
|
||||
status: scenario.status,
|
||||
status: "complete",
|
||||
output: scenario.output,
|
||||
});
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
@@ -93,20 +191,26 @@ test.describe("shell command output", () => {
|
||||
await session.waitForLoad();
|
||||
|
||||
const chat = session.activeChat();
|
||||
const command = chat.getByTestId("tool-execute-command").filter({
|
||||
const commandRow = chat.getByTestId("tool-execute-command").filter({
|
||||
hasText: scenario.command,
|
||||
});
|
||||
await expect(command).toBeVisible({ timeout: 15_000 });
|
||||
await expect(commandRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(chat.getByText(scenario.outputText)).toHaveCount(0);
|
||||
|
||||
const responsePromise = testPage.waitForResponse(
|
||||
(response) => response.url().endsWith("/shell-output") && response.status() === 200,
|
||||
);
|
||||
await chat.getByRole("button", { name: "Show command output" }).click();
|
||||
await responsePromise;
|
||||
|
||||
await expect(chat.getByText(scenario.outputText)).toBeVisible();
|
||||
await expect(chat.getByText(scenario.exitLabel)).toBeVisible();
|
||||
if (scenario.statusLabel) {
|
||||
await expect(chat.getByLabel(scenario.statusLabel)).toBeVisible();
|
||||
} else {
|
||||
await expect(chat.getByLabel("Command succeeded")).toHaveCount(0);
|
||||
await expect(chat.getByLabel("Command failed")).toHaveCount(0);
|
||||
}
|
||||
|
||||
await command.click();
|
||||
await expect(chat.getByText(scenario.outputText)).toBeVisible();
|
||||
await expect(chat.getByText(scenario.exitLabel)).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchShellCommandOutput,
|
||||
type ShellCommandOutputSnapshot,
|
||||
} from "@/lib/api/domains/session-api";
|
||||
import { useShellCommandOutput } from "./use-shell-command-output";
|
||||
|
||||
vi.mock("@/lib/api/domains/session-api", () => ({
|
||||
fetchShellCommandOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchOutputMock = vi.mocked(fetchShellCommandOutput);
|
||||
|
||||
function snapshot(
|
||||
status: string,
|
||||
stdout = "",
|
||||
output: ShellCommandOutputSnapshot["output"] = {},
|
||||
): ShellCommandOutputSnapshot {
|
||||
return {
|
||||
message_id: "message-1",
|
||||
status,
|
||||
updated_at: "2026-07-16T12:00:00Z",
|
||||
output: { ...output, ...(stdout ? { stdout } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchOutputMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useShellCommandOutput fetching", () => {
|
||||
it("does not fetch while collapsed and fetches one terminal snapshot immediately on open", async () => {
|
||||
fetchOutputMock.mockResolvedValue(snapshot("complete", "done", { exit_code: 0 }));
|
||||
const { result, rerender } = renderHook(
|
||||
({ isOpen }) =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen,
|
||||
messageStatus: "complete",
|
||||
}),
|
||||
{ initialProps: { isOpen: false } },
|
||||
);
|
||||
|
||||
expect(fetchOutputMock).not.toHaveBeenCalled();
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(result.current.snapshot?.output.stdout).toBe("done");
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => vi.advanceTimersByTime(10_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polls running output after settlement without overlapping requests", async () => {
|
||||
const second = deferred<ShellCommandOutputSnapshot>();
|
||||
fetchOutputMock
|
||||
.mockResolvedValueOnce(snapshot("running", "first"))
|
||||
.mockReturnValueOnce(second.promise)
|
||||
.mockResolvedValueOnce(snapshot("complete", "unexpected"));
|
||||
const { result } = renderHook(() =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus: "running",
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(result.current.snapshot?.output.stdout).toBe("first");
|
||||
await act(async () => vi.advanceTimersByTime(1_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
await act(async () => vi.advanceTimersByTime(10_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
second.resolve(snapshot("complete", "finished", { exit_code: 0 }));
|
||||
await flushPromises();
|
||||
expect(result.current.snapshot?.output.stdout).toBe("finished");
|
||||
await act(async () => vi.advanceTimersByTime(10_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("polls pending output while the projected message remains active", async () => {
|
||||
fetchOutputMock
|
||||
.mockResolvedValueOnce(snapshot("pending", "queued"))
|
||||
.mockResolvedValueOnce(snapshot("complete", "done", { exit_code: 0 }));
|
||||
const { result } = renderHook(() =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus: "pending",
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(result.current.snapshot?.output.stdout).toBe("queued");
|
||||
await act(async () => vi.advanceTimersByTime(1_000));
|
||||
await flushPromises();
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retains the latest snapshot and caps consecutive failure backoff at five seconds", async () => {
|
||||
fetchOutputMock
|
||||
.mockResolvedValueOnce(snapshot("running", "retained"))
|
||||
.mockRejectedValue(new Error("unavailable"));
|
||||
const { result } = renderHook(() =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus: "running",
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const advanceToNextFailure = async (delay: number, expectedCalls: number) => {
|
||||
await act(async () => vi.advanceTimersByTime(delay));
|
||||
await flushPromises();
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(expectedCalls);
|
||||
expect(result.current.snapshot?.output.stdout).toBe("retained");
|
||||
expect(result.current.error?.message).toBe("unavailable");
|
||||
};
|
||||
|
||||
await advanceToNextFailure(1_000, 2);
|
||||
await advanceToNextFailure(1_000, 3);
|
||||
await advanceToNextFailure(2_000, 4);
|
||||
await advanceToNextFailure(4_000, 5);
|
||||
await advanceToNextFailure(5_000, 6);
|
||||
await advanceToNextFailure(5_000, 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useShellCommandOutput cleanup", () => {
|
||||
it("aborts and ignores stale output after collapse", async () => {
|
||||
const pending = deferred<ShellCommandOutputSnapshot>();
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
fetchOutputMock.mockImplementation((_sessionId, _messageId, options) => {
|
||||
requestSignal = options?.init?.signal ?? undefined;
|
||||
return pending.promise;
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ isOpen }) =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen,
|
||||
messageStatus: "running",
|
||||
}),
|
||||
{ initialProps: { isOpen: true } },
|
||||
);
|
||||
|
||||
expect(requestSignal?.aborted).toBe(false);
|
||||
rerender({ isOpen: false });
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
pending.resolve(snapshot("running", "stale"));
|
||||
await flushPromises();
|
||||
expect(result.current.snapshot).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces in-flight work with one final fetch when the projected message becomes terminal", async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
const finalSnapshot = deferred<ShellCommandOutputSnapshot>();
|
||||
fetchOutputMock.mockImplementation((_sessionId, _messageId, options) => {
|
||||
const signal = options?.init?.signal;
|
||||
if (signal) signals.push(signal);
|
||||
if (signals.length === 2) return finalSnapshot.promise;
|
||||
return new Promise<ShellCommandOutputSnapshot>(() => undefined);
|
||||
});
|
||||
const projectedTerminal = renderHook(
|
||||
({ messageStatus }) =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus,
|
||||
}),
|
||||
{ initialProps: { messageStatus: "running" } },
|
||||
);
|
||||
|
||||
projectedTerminal.rerender({ messageStatus: "complete" });
|
||||
expect(signals[0]?.aborted).toBe(true);
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals[1]?.aborted).toBe(false);
|
||||
finalSnapshot.resolve(snapshot("complete", "final output", { exit_code: 0 }));
|
||||
await flushPromises();
|
||||
expect(projectedTerminal.result.current.snapshot?.output.stdout).toBe("final output");
|
||||
await act(async () => vi.advanceTimersByTime(10_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
projectedTerminal.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useShellCommandOutput terminal lifecycle", () => {
|
||||
it("aborts in-flight work on unmount", () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
fetchOutputMock.mockImplementation((_sessionId, _messageId, options) => {
|
||||
const signal = options?.init?.signal;
|
||||
if (signal) signals.push(signal);
|
||||
return new Promise<ShellCommandOutputSnapshot>(() => undefined);
|
||||
});
|
||||
const unmounted = renderHook(() =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus: "running",
|
||||
}),
|
||||
);
|
||||
expect(signals).toHaveLength(1);
|
||||
unmounted.unmount();
|
||||
expect(signals[0]?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["completed", "success", "failed"])(
|
||||
"treats the projected %s alias as terminal after the final snapshot",
|
||||
async (terminalStatus) => {
|
||||
fetchOutputMock
|
||||
.mockResolvedValueOnce(snapshot("running", "partial"))
|
||||
.mockResolvedValueOnce(snapshot("running", "finalizing"));
|
||||
const view = renderHook(
|
||||
({ messageStatus }) =>
|
||||
useShellCommandOutput({
|
||||
sessionId: "session-1",
|
||||
messageId: "message-1",
|
||||
isOpen: true,
|
||||
messageStatus,
|
||||
}),
|
||||
{ initialProps: { messageStatus: "running" } },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
view.rerender({ messageStatus: terminalStatus });
|
||||
await flushPromises();
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
await act(async () => vi.advanceTimersByTime(10_000));
|
||||
expect(fetchOutputMock).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchShellCommandOutput,
|
||||
type ShellCommandOutputSnapshot,
|
||||
} from "@/lib/api/domains/session-api";
|
||||
import { isTerminalToolCallStatus } from "@/lib/utils/tool-call-status";
|
||||
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const MAX_RETRY_INTERVAL_MS = 5_000;
|
||||
|
||||
export type UseShellCommandOutputOptions = {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
isOpen: boolean;
|
||||
messageStatus?: string;
|
||||
};
|
||||
|
||||
export type UseShellCommandOutputResult = {
|
||||
snapshot: ShellCommandOutputSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
type PollOperation = {
|
||||
generation: number;
|
||||
controller: AbortController | null;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
function retryDelay(failureCount: number) {
|
||||
return Math.min(POLL_INTERVAL_MS * 2 ** Math.max(0, failureCount - 1), MAX_RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function asError(error: unknown) {
|
||||
return error instanceof Error ? error : new Error("Command output unavailable");
|
||||
}
|
||||
|
||||
export function useShellCommandOutput({
|
||||
sessionId,
|
||||
messageId,
|
||||
isOpen,
|
||||
messageStatus,
|
||||
}: UseShellCommandOutputOptions): UseShellCommandOutputResult {
|
||||
const [snapshot, setSnapshot] = useState<ShellCommandOutputSnapshot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const snapshotRef = useRef<ShellCommandOutputSnapshot | null>(null);
|
||||
const outputKeyRef = useRef("");
|
||||
const messageStatusRef = useRef(messageStatus);
|
||||
const operationRef = useRef<PollOperation>({ generation: 0, controller: null, timer: null });
|
||||
messageStatusRef.current = messageStatus;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const operation = operationRef.current;
|
||||
operation.generation += 1;
|
||||
if (operation.timer) clearTimeout(operation.timer);
|
||||
operation.timer = null;
|
||||
operation.controller?.abort();
|
||||
operation.controller = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
stop();
|
||||
if (!isOpen || !sessionId || !messageId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputKey = `${sessionId}:${messageId}`;
|
||||
if (outputKeyRef.current !== outputKey) {
|
||||
outputKeyRef.current = outputKey;
|
||||
snapshotRef.current = null;
|
||||
setSnapshot(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const operation = operationRef.current;
|
||||
const generation = operation.generation;
|
||||
let failureCount = 0;
|
||||
|
||||
const requestSnapshot = async () => {
|
||||
const controller = new AbortController();
|
||||
operation.controller = controller;
|
||||
if (!snapshotRef.current) setIsLoading(true);
|
||||
try {
|
||||
const nextSnapshot = await fetchShellCommandOutput(sessionId, messageId, {
|
||||
init: { signal: controller.signal },
|
||||
});
|
||||
if (operation.generation !== generation || controller.signal.aborted) return;
|
||||
operation.controller = null;
|
||||
failureCount = 0;
|
||||
snapshotRef.current = nextSnapshot;
|
||||
setSnapshot(nextSnapshot);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
if (
|
||||
!isTerminalToolCallStatus(nextSnapshot.status) &&
|
||||
!isTerminalToolCallStatus(messageStatusRef.current)
|
||||
) {
|
||||
operation.timer = setTimeout(requestSnapshot, POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (requestError) {
|
||||
if (operation.generation !== generation || controller.signal.aborted) return;
|
||||
operation.controller = null;
|
||||
failureCount += 1;
|
||||
setError(asError(requestError));
|
||||
setIsLoading(false);
|
||||
if (!isTerminalToolCallStatus(messageStatusRef.current)) {
|
||||
operation.timer = setTimeout(requestSnapshot, retryDelay(failureCount));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void requestSnapshot();
|
||||
return stop;
|
||||
}, [isOpen, messageId, messageStatus, retryVersion, sessionId, stop]);
|
||||
|
||||
const retry = useCallback(() => setRetryVersion((version) => version + 1), []);
|
||||
return { snapshot, isLoading, error, retry };
|
||||
}
|
||||
@@ -7,6 +7,20 @@ import type {
|
||||
} from "@/lib/types/http";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
|
||||
export type ShellCommandOutput = {
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
export type ShellCommandOutputSnapshot = {
|
||||
message_id: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
output: ShellCommandOutput;
|
||||
};
|
||||
|
||||
export type MessageSearchHit = {
|
||||
id: string;
|
||||
turn_id?: string;
|
||||
@@ -85,6 +99,17 @@ export async function listTaskSessionMessages(
|
||||
return fetchJson<ListMessagesResponse>(url, options);
|
||||
}
|
||||
|
||||
export async function fetchShellCommandOutput(
|
||||
taskSessionId: string,
|
||||
messageId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<ShellCommandOutputSnapshot>(
|
||||
`/api/v1/task-sessions/${taskSessionId}/messages/${messageId}/shell-output`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listSessionTurns(taskSessionId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<ListTurnsResponse>(`/api/v1/task-sessions/${taskSessionId}/turns`, options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type NormalizedToolCallStatus = "pending" | "running" | "complete" | "error" | "cancelled";
|
||||
|
||||
export function normalizeToolCallStatus(
|
||||
status: string | undefined,
|
||||
): NormalizedToolCallStatus | undefined {
|
||||
if (status === "in_progress") return "running";
|
||||
if (status === "completed" || status === "success") return "complete";
|
||||
if (status === "failed") return "error";
|
||||
if (
|
||||
status === "pending" ||
|
||||
status === "running" ||
|
||||
status === "complete" ||
|
||||
status === "error" ||
|
||||
status === "cancelled"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isTerminalToolCallStatus(status: string | undefined) {
|
||||
const normalized = normalizeToolCallStatus(status);
|
||||
return normalized === "complete" || normalized === "error" || normalized === "cancelled";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-0042: Project Shell Output and Fetch It on Demand
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-07-16
|
||||
**Area:** backend, frontend, protocol
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0036 stores a bounded normalized shell transcript on each tool message so live and reloaded chat agree. The same metadata is currently serialized into task boot state, REST and WebSocket message lists, and every live message update, even though output is collapsed or uninspected in most conversations. CSS-only collapse reduces vertical space but does not reduce serialization, transfer, parsing, or retained browser-memory costs.
|
||||
|
||||
## Decision
|
||||
|
||||
Persist the full bounded shell output in the existing message metadata, but separate its browser-facing summary from its body.
|
||||
|
||||
- A shared task-message projection replaces `stdout` and `stderr` with `has_output`, retained UTF-8 byte counts, `truncated`, and nullable `exit_code` before messages enter normal REST, boot, WebSocket-list, or WebSocket-notification payloads. Projection handles both the typed normalized payload used during live updates and the generic map produced by database JSON decoding, and never mutates stored metadata.
|
||||
- A session-scoped HTTP endpoint, `GET /api/v1/task-sessions/:session_id/messages/:message_id/shell-output`, returns the latest full snapshot plus tool status and message `updated_at`. It returns `404` for an absent, cross-session, or non-shell message.
|
||||
- The frontend fetches that endpoint only while the output disclosure is open. Completed commands require one snapshot request. Expanded running commands use one non-overlapping poll loop with a one-second base interval and a five-second maximum retry interval. A projected transition to terminal aborts the active poll and performs one final snapshot request before stopping, so the expanded transcript cannot remain on a partial running snapshot. Collapse or unmount stops the loop and aborts in-flight work without a final fetch.
|
||||
- Persistence, provider normalization, output bounds, and the normal message store remain unchanged. No table or migration is added.
|
||||
|
||||
The observable contract is defined in [the ACP shell command output spec](../specs/ui/acp-shell-command-output.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Large shell bodies are absent from the message paths used on nearly every task view, including repeated live updates, while command text, result status, truncation, and approximate size remain available for compact rendering.
|
||||
- Expanding output incurs an additional database read and HTTP request. Running output incurs bounded polling only for disclosures the user chose to open.
|
||||
- All browser-facing message paths must use the same projection helper; bypassing it would regress both payload size and the no-body contract.
|
||||
- Full output remains part of the message row, so the endpoint reads and decodes the existing metadata blob. This avoids a migration but does not reduce database row size or the cost of the selected row read.
|
||||
- Output snapshots are whole bounded values rather than deltas. The existing 256 KiB per-field limit caps response size and keeps polling implementation simple.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Collapse only in React
|
||||
|
||||
Rejected because it fixes vertical space but still sends, parses, and retains every transcript in boot, list, and live-update payloads.
|
||||
|
||||
### Move output to a separate table or blob store
|
||||
|
||||
Rejected for this iteration because existing bounded metadata already provides durable ownership and lifecycle semantics. Separate storage would require a migration and coordinated deletion without first proving database-row reads are the bottleneck.
|
||||
|
||||
### Subscribe to output deltas over WebSocket
|
||||
|
||||
Rejected because it adds per-disclosure subscription lifecycle and replay complexity. Snapshot polling is sufficient for an explicitly opened, bounded diagnostic view and stops when terminal.
|
||||
|
||||
### Send a shortened output preview with every message
|
||||
|
||||
Rejected because previews still multiply payload cost across boot, pagination, backfill, and live updates, and the compact row does not require transcript content.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-0036: Normalize ACP shell output at the adapter boundary](0036-normalize-acp-shell-output-at-adapter-boundary.md)
|
||||
- [ACP shell command output spec](../specs/ui/acp-shell-command-output.md)
|
||||
- [Implementation plan](../plans/acp-shell-command-output/plan.md)
|
||||
@@ -47,3 +47,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 0039 | [Native desktop integration boundary](0039-native-desktop-integration-boundary.md) | accepted | desktop, frontend, backend, infra | 2026-07-15 |
|
||||
| 0040 | [Separate updater integrity from OS publisher identity](0040-separate-updater-integrity-from-os-publisher-identity.md) | accepted | desktop, infra, workflow | 2026-07-15 |
|
||||
| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted | backend, frontend | 2026-07-15 |
|
||||
| 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 |
|
||||
|
||||
@@ -1,149 +1,190 @@
|
||||
---
|
||||
spec: docs/specs/ui/acp-shell-command-output.md
|
||||
created: 2026-07-14
|
||||
status: complete
|
||||
updated: 2026-07-16
|
||||
status: done
|
||||
---
|
||||
|
||||
# Implementation Plan: ACP Shell Command Output
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing normalized shell payload rather than adding a transport or storage layer. First define bounded, nullable provider normalization, then wire ACP capability negotiation and live tool updates into it. Finally update the existing command row and verify its persisted desktop/mobile rendering.
|
||||
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
|
||||
|
||||
### Normalized Shell Output Contract
|
||||
### Shared Client Message Projection
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/backend/internal/agentctl/types/streams/tool_payload.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output.go` (new, if extraction keeps `normalize.go` within limits)
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output_test.go` (new, if paired with the helper)
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/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.go`
|
||||
- `apps/backend/internal/task/service/service_events_test.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Change `ShellExecOutput.ExitCode` to a nullable pointer and add `Truncated`.
|
||||
- Replace the current plain-string-implies-zero behavior with provider-aware extraction for Codex `formatted_output`/`exit_code`, OpenCode `metadata.exit`, Auggie XML, and plain fallback output.
|
||||
- Add one bounded text helper with a 256 KiB per-field limit that retains the newest valid UTF-8 content.
|
||||
- Encode output update mode explicitly: Codex delta appends; Claude terminal output, OpenCode cumulative content, and final aggregates replace. Final missing output preserves accumulated text.
|
||||
- Apply exit precedence from the spec and keep unknown nullable.
|
||||
- Add one structured metadata helper that recognizes both live typed `*streams.NormalizedPayload` values and generic maps decoded from persisted JSON.
|
||||
- `ProjectMessageMetadata` uses copy-on-write along the normalized shell path, removes shell `stdout`/`stderr`, and adds `has_output`, `stdout_bytes`, and `stderr_bytes` while retaining nullable `exit_code` and `truncated`.
|
||||
- Route `Message.ToAPI()` and `publishMessageEvent` through that helper. This covers REST lists, WebSocket `message.list`, task-route boot hydration, and `session.message.added` / `session.message.updated` without 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.
|
||||
|
||||
### ACP Capability and Live Updates
|
||||
### On-Demand Output Snapshot
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go` or a focused initialize test file
|
||||
- `apps/backend/internal/task/models/message_shell_output.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers_test.go`
|
||||
- `apps/backend/internal/task/dto/dto.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Set `ClientCapabilities.Meta["terminal_output"] = true` on the initialize request without advertising ACP terminal RPC support.
|
||||
- Feed recognized `_meta.terminal_output_delta`, `_meta.terminal_output`, `_meta.terminal_exit`, text content, and raw output into the shell normalizer while the adapter lock owns the active payload.
|
||||
- Synthesize `in_progress` only for recognized statusless terminal-output updates so the orchestrator persists them.
|
||||
- Normalize before deleting terminal tool state; keep the existing prompt-end cleanup behavior.
|
||||
|
||||
No orchestrator, database, HTTP, or WebSocket changes are planned. `tool_update` already replaces and publishes the persisted normalized message metadata.
|
||||
- Add `GET /api/v1/task-sessions/:session_id/messages/:message_id/shell-output` beside the existing message routes.
|
||||
- Load through `Service.GetMessage`, require the row to belong to the path session, and extract a normalized shell snapshot with `message_id`, tool `status`, `updated_at`, and the persisted output fields.
|
||||
- Return `404` for absent, cross-session, or non-shell messages; return `200` with 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
|
||||
|
||||
### Expandable Command Row
|
||||
### Output API and Polling Hook
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/web/lib/api/domains/session-api.ts`
|
||||
- `apps/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`
|
||||
- `apps/web/components/task/chat/messages/tool-execute-message.tsx`
|
||||
- `apps/web/components/task/chat/messages/tool-execute-message.test.tsx` (new)
|
||||
|
||||
Changes:
|
||||
|
||||
- Model exit status as `number | null | undefined` and add `truncated` to the shared shell output type.
|
||||
- Remove the current absent-exit-means-success rule.
|
||||
- Render live/final combined output in the current expanded content, plus a completion footer with `Exit code N` or `Exit code unavailable` and a truncation label when needed.
|
||||
- Keep known zero success, known nonzero/error failure, and unknown neutral. Preserve stable sizing, wrapping, horizontal scrolling, and the existing expand-state behavior.
|
||||
- Split the browser type into `ShellExecOutputSummary` for message metadata and `ShellCommandOutputSnapshot` for 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.
|
||||
|
||||
No new state, hook, or API client is needed.
|
||||
### Command Row and Output Disclosure
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/web/components/task/chat/messages/tool-execute-message.tsx`
|
||||
- `apps/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-wrap` and 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:** provider-specific output and exit extraction, precedence, unknown exit, append/replace behavior, deduplication, bounds, and UTF-8 safety.
|
||||
**File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go` and/or `shell_output_test.go`.
|
||||
**How:** table-driven tests built from the captured Codex, Claude, OpenCode, and Auggie frame shapes.
|
||||
- **What:** statusless terminal metadata becomes persisted `in_progress`, final updates retain output and exit, and terminal state is cleaned up.
|
||||
**File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go`.
|
||||
**How:** drive initial tool call plus incremental/final `SessionToolCallUpdate` values through the adapter conversion methods.
|
||||
- **What:** ACP initialization advertises only the terminal-output meta extension required by Claude.
|
||||
**File:** focused adapter initialize test or `conversion_test.go`.
|
||||
**How:** capture the fake agent's `InitializeRequest` and assert `_meta.terminal_output == true`.
|
||||
- **What:** exit `0`, nonzero, and unknown render distinct status; live output and truncation are visible.
|
||||
- **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.go` and `apps/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 for `200` populated, `200` empty-running, missing `404`, cross-session `404`, and non-shell `404`.
|
||||
- **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 cases over persisted `Message` fixtures.
|
||||
|
||||
---
|
||||
**How:** React Testing Library with endpoint hook/API mocks and long-command fixtures.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN persisted successful, failed, and unknown-exit command messages, WHEN desktop chat expands each row, THEN combined output and the exact exit label are visible and unknown is not presented as success.
|
||||
- **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 a persisted command with long wrapped output on a narrow viewport, WHEN mobile chat expands the row, THEN output, truncation state, and exit label remain readable without overlap or horizontal page overflow.
|
||||
- **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.ts` using 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`.
|
||||
|
||||
The provider wire shapes are covered at the backend adapter level; E2E seeds the normalized persisted contract so it remains deterministic and does not require four external authenticated CLIs.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1:
|
||||
Completed waves:
|
||||
|
||||
- [x] [task-01-backend-normalization](task-01-backend-normalization.md) (`done`)
|
||||
|
||||
Wave 2 (parallel after Task 01):
|
||||
|
||||
- [x] [task-02-acp-live-updates](task-02-acp-live-updates.md) (`done`)
|
||||
- [x] [task-03-frontend-command-row](task-03-frontend-command-row.md) (`done`)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [task-04-e2e-and-verification](task-04-e2e-and-verification.md) (`done`)
|
||||
|
||||
Follow-up waves:
|
||||
|
||||
Wave 4:
|
||||
|
||||
- [x] [task-05-backend-output-projection](task-05-backend-output-projection.md) (`done`)
|
||||
|
||||
Wave 5:
|
||||
|
||||
- [x] [task-06-frontend-output-disclosure](task-06-frontend-output-disclosure.md) (`done`)
|
||||
|
||||
Wave 6:
|
||||
|
||||
- [ ] [task-07-lazy-output-e2e](task-07-lazy-output-e2e.md) (`in_progress`)
|
||||
|
||||
Dependency graph:
|
||||
|
||||
```text
|
||||
01-backend-normalization
|
||||
|-- 02-acp-live-updates --|
|
||||
`-- 03-frontend-command-row --+--> 04-e2e-and-verification
|
||||
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
|
||||
|
||||
```bash
|
||||
make -C apps/backend fmt
|
||||
(cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp)
|
||||
(cd apps && pnpm --filter @kandev/web test -- 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 -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
|
||||
|
||||
- Provider extensions are not part of the stable ACP schema. Parsing remains defensive and scoped to shell payloads so unrelated `_meta` cannot mutate other tool kinds.
|
||||
- Codex may omit output generated immediately after process start upstream. Kandev can only preserve bytes present in ACP frames; provider-side loss is explicitly out of scope.
|
||||
- OpenCode reports nonzero commands with ACP status `completed`; UI success must therefore use a known exit code before the generic completed status.
|
||||
- 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()` and `publishMessageEvent` are separate delivery paths today. Tests must prevent a future path from leaking full bodies even though boot and list routes already reuse `ToAPI()`.
|
||||
- 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 256 KiB per-field bound and tail-retention behavior are explicit product constraints for this implementation.
|
||||
None. The endpoint shape, summary fields, polling bounds, storage choice, and failure behavior are fixed by the spec and ADR.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: "05-backend-output-projection"
|
||||
title: "Backend shell output projection"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["04-e2e-and-verification"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/acp-shell-command-output.md"
|
||||
---
|
||||
|
||||
# Task 05: Backend Shell Output Projection
|
||||
|
||||
## Acceptance
|
||||
|
||||
- REST message lists, WebSocket `message.list`, task boot state, and live message notifications carry shell output summary fields but never `stdout` or `stderr`; typed live metadata and persisted map metadata produce the same non-mutating projection.
|
||||
- `GET /api/v1/task-sessions/:session_id/messages/:message_id/shell-output` returns the latest bounded snapshot, status, and update timestamp for a shell message, including a valid empty-running snapshot.
|
||||
- Missing, cross-session, and non-shell message IDs return `404`, while all existing non-shell message metadata remains compatible.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make -C apps/backend fmt
|
||||
make -C apps/backend test
|
||||
make -C apps/backend lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/backend/internal/task/models/message_shell_output.go` (new)
|
||||
- `apps/backend/internal/task/models/message_shell_output_test.go` (new)
|
||||
- `apps/backend/internal/task/dto/dto.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers_test.go`
|
||||
- `apps/backend/internal/task/service/service_events.go`
|
||||
- `apps/backend/internal/task/service/service_events_test.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 04 provides the persisted normalized output contract and shipped baseline. No schema or ACP adapter change is permitted.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections `Data model`, `API surface`, `Failure modes`, and the summary/on-demand scenarios.
|
||||
- ADR-0042 projection boundary and storage decision.
|
||||
- Existing paths: `models.Message.ToAPI`, `service.publishMessageEvent`, `handlers.messagesToAPI`, `handlers.wsListMessages`, and `bootStateBuilder.addTaskDetailActiveTaskState`.
|
||||
- Existing repository behavior: live metadata may contain `*streams.NormalizedPayload`; rows read from SQLite contain `map[string]any`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the projection/extraction API, response/error mapping, typed-versus-map coverage, payload paths proven body-free, files changed, exact tests run, blockers, and residual risks. Set this task to `in_progress` before code and `done` with the matching `plan.md` checkbox only after targeted tests and lint pass.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: "06-frontend-output-disclosure"
|
||||
title: "Frontend shell output disclosure"
|
||||
status: done
|
||||
wave: 5
|
||||
depends_on: ["05-backend-output-projection"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/acp-shell-command-output.md"
|
||||
---
|
||||
|
||||
# Task 06: Frontend Shell Output Disclosure
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The complete normalized command and working directory remain visible and wrap on desktop/mobile, while running and terminal output disclosures start collapsed and no output request occurs while collapsed.
|
||||
- Opening fetches one snapshot; running output refreshes with one non-overlapping request at a time, one-second success cadence, and five-second maximum failure backoff. A projected terminal transition replaces active polling with one final snapshot; collapse or unmount aborts immediately.
|
||||
- Loading, empty, populated stdout/stderr, unavailable/retry, truncation, known exit, and unknown exit states render from the snapshot without reading transcript bodies from message metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/api/domains/session-api.ts`
|
||||
- `apps/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`
|
||||
- `apps/web/components/task/chat/messages/tool-execute-message.tsx`
|
||||
- `apps/web/components/task/chat/messages/shell-output-disclosure.tsx` (new, if needed)
|
||||
- `apps/web/components/task/chat/messages/tool-execute-message.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 05 defines and tests the exact summary and snapshot endpoint contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec `What`, `API surface`, failure scenarios, and desktop/mobile scenarios.
|
||||
- ADR-0042 polling and component-local state decision.
|
||||
- `apps/web/AGENTS.md` data-flow, component-size, accessibility, and mobile constraints.
|
||||
- Existing `ToolExecuteMessage`, `normalizeToolCallStatus`, shared `fetchJson`, and path transformation behavior.
|
||||
- Invoke `/mobile-parity` and `/tdd` while implementing this user-facing task.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the disclosure interaction, fetch/poll lifecycle, stale-response guards, status/empty/error rendering, desktop/mobile layout considerations, files changed, exact tests run, blockers, and residual risks. Set this task to `in_progress` before code and `done` with the matching `plan.md` checkbox only after tests, typecheck, and lint pass.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: "07-lazy-output-e2e"
|
||||
title: "Lazy shell output E2E verification"
|
||||
status: done
|
||||
wave: 6
|
||||
depends_on: ["06-frontend-output-disclosure"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/acp-shell-command-output.md"
|
||||
---
|
||||
|
||||
# Task 07: Lazy Shell Output E2E Verification
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves the full command is visible while output starts collapsed, no snapshot request occurs before expansion, and expanding a completed command fetches and renders its persisted transcript/result.
|
||||
- Desktop E2E proves an expanded running command refreshes through a controlled sequence and stops requesting after terminal status; mobile E2E proves long commands and output stay within the viewport without overlap.
|
||||
- Targeted E2E plus backend/frontend format, tests, typecheck, and lint pass, and the spec/plan statuses are updated only after all acceptance criteria are satisfied.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
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 -C apps/backend fmt
|
||||
cd apps/backend && go test ./internal/task/models ./internal/task/handlers ./internal/task/service ./internal/backendapp
|
||||
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
|
||||
make fmt
|
||||
make typecheck
|
||||
make test
|
||||
make lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/chat/tool-execute-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-tool-execute-output.spec.ts`
|
||||
- `apps/web/e2e/pages/session-page.ts` only if a reusable active-chat disclosure helper removes duplication
|
||||
- `docs/specs/ui/acp-shell-command-output.md` for status only, unless verification reveals a genuine contract correction
|
||||
- `docs/specs/INDEX.md` for matching status
|
||||
- `docs/plans/acp-shell-command-output/plan.md`
|
||||
- `docs/plans/acp-shell-command-output/task-07-lazy-output-e2e.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 05 and 06 must be integrated. This task changes no output storage or API behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
- All new lazy-output spec scenarios.
|
||||
- Existing desktop/mobile shell-output E2E fixtures and `SessionPage.activeChat()` scoping.
|
||||
- Task 05 handler contract and Task 06 test IDs/disclosure behavior.
|
||||
- Invoke `/e2e`, `/mobile-parity`, `/qa`, and `/verify` for this integrated verification task.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report network-request assertions, polling sequence/count, desktop/mobile projects, viewport checks, all verification commands and results, artifact paths for failures, files changed, blockers, and remaining risks. Set this task and plan to `done` and the spec/index to `shipped` only after the full acceptance and verification set passes.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-14
|
||||
updated: 2026-07-16
|
||||
owner: cfl
|
||||
---
|
||||
|
||||
@@ -8,7 +9,7 @@ owner: cfl
|
||||
|
||||
## Why
|
||||
|
||||
Agent chat shows that a shell command ran, but ACP agents encode terminal output and exit status in different fields. Users need the expandable command row to preserve the actual terminal output and distinguish success, failure, and an unavailable exit status without knowing which agent produced the command.
|
||||
Agent chat shows that a shell command ran, but ACP agents encode terminal output and exit status in different fields. Users need the exact command and result status at a glance without large terminal transcripts consuming most of the conversation or being transferred when they are rarely inspected.
|
||||
|
||||
## What
|
||||
|
||||
@@ -24,7 +25,11 @@ Agent chat shows that a shell command ran, but ACP agents encode terminal output
|
||||
- Exit-code precedence is `_meta.terminal_exit.exit_code`, provider-native structured exit fields, then Auggie's `<return-code>`. An absent or unparseable exit status remains unknown; it MUST NOT become exit `0`.
|
||||
- Terminal text is treated as a combined stream unless an agent explicitly supplies separate stdout and stderr fields. Kandev does not infer stream separation from line ordering.
|
||||
- Each normalized output text field is bounded to 256 KiB. When a field exceeds the bound, Kandev retains its most recent valid UTF-8 content and sets `truncated: true`.
|
||||
- The existing expandable command row shows combined output in a scrollable monospace region. On terminal completion it visibly shows `Exit code N` when known, or `Exit code unavailable` when unknown. Unknown is neutral, not success or failure.
|
||||
- The full normalized command is always visible and wraps instead of truncating. The message content remains its fallback when a normalized command is absent. The working directory, when present, remains visible with the command.
|
||||
- Stdout and stderr render in a separate disclosure that is collapsed by default for both running and completed commands. Running commands never auto-expand their output.
|
||||
- Expanding the disclosure fetches the latest output snapshot on demand. A collapsed disclosure does not fetch or mount the output body.
|
||||
- While an expanded command is running, Kandev refreshes its output with non-overlapping polling until the command becomes complete, failed, or cancelled. Polling stops immediately when the disclosure collapses or unmounts.
|
||||
- The expanded disclosure shows combined output in a scrollable monospace region. On terminal completion it visibly shows `Exit code N` when known, or `Exit code unavailable` when unknown. Unknown is neutral, not success or failure.
|
||||
- A known exit code of `0` is success. A known nonzero exit code is failure even when an agent reports ACP status `completed`. ACP `failed`/`error` remains failure independently of whether an exit code is available.
|
||||
- ACP cancellation is terminal and preserves the transcript while showing `Exit code unavailable` when no exit was reported.
|
||||
- Desktop and mobile chat expose the same output, truncation indication, and exit-status semantics.
|
||||
@@ -43,9 +48,55 @@ metadata.normalized.shell_exec.output
|
||||
|
||||
An explicit `exit_code: 0` is distinct from an absent exit code.
|
||||
|
||||
Normal client message projections replace the persisted output body with this summary at the same path:
|
||||
|
||||
```text
|
||||
metadata.normalized.shell_exec.output
|
||||
exit_code integer optional; absent means unknown
|
||||
truncated boolean true when either persisted field hit its bound
|
||||
has_output boolean true when retained stdout or stderr is non-empty
|
||||
stdout_bytes integer UTF-8 byte count of retained stdout
|
||||
stderr_bytes integer UTF-8 byte count of retained stderr
|
||||
```
|
||||
|
||||
`stdout` and `stderr` MUST be absent from message list, boot-state, and WebSocket message payloads. Projection does not mutate the persisted message metadata.
|
||||
|
||||
## API surface
|
||||
|
||||
This feature extends the existing `NormalizedPayload.shell_exec` JSON contract carried by agent stream events and task messages. It adds no HTTP route, WebSocket event type, or frontend store slice.
|
||||
This feature extends the existing `NormalizedPayload.shell_exec` contract. Agent stream events and persistence retain the full bounded output, while all browser-facing message projections carry only the output summary defined above. This applies to:
|
||||
|
||||
- `GET /api/v1/task-sessions/:session_id/messages` and its agent-session alias.
|
||||
- The `message.list` WebSocket response.
|
||||
- `session.message.added` and `session.message.updated` WebSocket notifications.
|
||||
- Task-route boot state under `initialState.messages`.
|
||||
|
||||
The full output snapshot is available from:
|
||||
|
||||
```http
|
||||
GET /api/v1/task-sessions/:session_id/messages/:message_id/shell-output
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "message-id",
|
||||
"status": "running",
|
||||
"updated_at": "2026-07-16T12:00:00Z",
|
||||
"output": {
|
||||
"exit_code": 0,
|
||||
"stdout": "retained stdout",
|
||||
"stderr": "retained stderr",
|
||||
"truncated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`exit_code`, `stdout`, and `stderr` are omitted when unavailable. The endpoint returns `404` when the message does not exist in the path session or is not a normalized shell-execution message; this avoids exposing cross-session message existence. It returns `200` with an empty `output` object when the shell command is valid but has not emitted output yet.
|
||||
|
||||
The frontend issues one immediate request when the disclosure opens. For a running response it schedules the next request only after the previous request settles, starting at one second and backing off on consecutive failures to at most five seconds. It keeps the latest successful snapshot during a transient failure and resumes the base interval after success. A terminal response stops recurring polling. When the normal message projection transitions to a terminal status while the disclosure is open, the frontend aborts any in-flight poll, fetches one final snapshot, and then stops. Collapse or unmount stops polling and aborts any in-flight request without a final fetch. This behavior uses component-local hook state, not the session message store.
|
||||
|
||||
Decision: [ADR-0042](../../decisions/0042-project-shell-output-and-fetch-on-demand.md).
|
||||
|
||||
ACP initialization includes:
|
||||
|
||||
@@ -67,10 +118,12 @@ The extension is additive: agents that ignore it continue through their current
|
||||
- Output exceeding the bound is truncated deterministically and remains valid UTF-8.
|
||||
- Unknown exit status never renders a success check or a failure cross solely because the value is absent.
|
||||
- ACP `failed` and `cancelled` statuses terminate active-call tracking even when no exit code is available.
|
||||
- A failed on-demand request leaves the disclosure open, retains the last successful snapshot, and shows an output-unavailable state until a retry succeeds. It never falls back to a body from the normal message payload.
|
||||
- Poll responses that arrive after collapse, unmount, or a newer request, including the final terminal snapshot request, are ignored.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
Live output updates use the existing tool-message update path and are persisted with message metadata. The latest bounded output and final exit status survive reloads. In-memory per-tool accumulation is discarded when the tool reaches a terminal state, the prompt is swept, or the adapter stops.
|
||||
Live output updates use the existing tool-message update path and are persisted with message metadata. The latest bounded output and final exit status survive reloads and are read by the on-demand endpoint. The summary projection is computed at delivery time; it is not separately persisted. In-memory per-tool accumulation is discarded when the tool reaches a terminal state, the prompt is swept, or the adapter stops.
|
||||
|
||||
## Scenarios
|
||||
|
||||
@@ -81,7 +134,12 @@ Live output updates use the existing tool-message update path and are persisted
|
||||
- **GIVEN** an agent returns plain output with no structured or embedded exit status, **WHEN** the command completes, **THEN** the output is visible and the row shows `Exit code unavailable` with neutral status.
|
||||
- **GIVEN** a command is cancelled without an exit code, **WHEN** its terminal update is persisted, **THEN** the transcript remains expandable and shows `Exit code unavailable`.
|
||||
- **GIVEN** terminal output exceeds 256 KiB, **WHEN** live and final updates are normalized, **THEN** stored output remains within the bound, keeps the newest valid UTF-8 text, and the expanded row indicates truncation.
|
||||
- **GIVEN** a persisted completed shell message, **WHEN** chat is opened on desktop or mobile and the row is expanded, **THEN** its output and exit status are readable without overlapping adjacent chat content.
|
||||
- **GIVEN** a persisted shell message with a long command, **WHEN** chat opens on desktop or mobile, **THEN** the complete command wraps visibly while its output disclosure remains collapsed.
|
||||
- **GIVEN** a collapsed shell command with persisted output, **WHEN** task boot state, message REST/WS lists, and live message notifications reach the browser, **THEN** they contain byte counts and result metadata but no stdout or stderr body, and no output endpoint request occurs.
|
||||
- **GIVEN** a persisted completed shell message, **WHEN** the user expands its output disclosure, **THEN** exactly one on-demand snapshot is fetched and its output, truncation, and exit status are readable without overlapping adjacent chat content.
|
||||
- **GIVEN** a running shell message with its disclosure expanded, **WHEN** output changes and the command later completes, **THEN** bounded non-overlapping polling refreshes the transcript and stops after the terminal snapshot.
|
||||
- **GIVEN** an expanded running shell message, **WHEN** the user collapses it or navigates away, **THEN** scheduled polling stops and an in-flight request cannot update the unmounted disclosure.
|
||||
- **GIVEN** an output request fails after a prior snapshot succeeded, **WHEN** the disclosure remains expanded, **THEN** the prior transcript stays visible, an unavailable state is shown, and capped retry polling can recover.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -89,6 +147,7 @@ Live output updates use the existing tool-message update path and are persisted
|
||||
- Fixing provider-side output loss before an ACP frame reaches Kandev.
|
||||
- Adding a terminal emulator, ANSI replay, command re-run action, download action, or searchable output viewer to chat.
|
||||
- Changing non-ACP adapters or the standalone terminal panel.
|
||||
- Moving shell output into a new table, object store, or streaming/chunked output protocol.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
|
||||
Reference in New Issue
Block a user