diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index bdfc3ffb0d..067a4ec784 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -29,6 +29,7 @@ var scenarioRegistry = map[string]func(e *emitter){ "untracked-file-modify": scenarioUntrackedFileModify, "clarification": scenarioClarification, "clarification-timeout": scenarioClarificationTimeout, + "multi-permission": scenarioMultiPermission, "review-cumulative-setup": scenarioReviewCumulativeSetup, "symlink-file-setup": scenarioSymlinkFileSetup, } @@ -96,6 +97,34 @@ func scenarioReadAndEdit(e *emitter) { e.text("Read and edit scenario complete.") } +// scenarioMultiPermission: three sequential bash tools, each requiring permission. +// Reproduces the "approvals reappear at turn complete" bug — user approves all +// three, then when the turn ends the approval prompts must NOT come back. +func scenarioMultiPermission(e *emitter) { + fixedDelay(50) + e.text("Running three commands that need approval.") + + for i, label := range []string{"first", "second", "third"} { + fixedDelay(50) + id := nextToolID() + input := map[string]any{ + "command": fmt.Sprintf("echo %s", label), + "description": fmt.Sprintf("Step %d: %s", i+1, label), + } + e.startTool(id, fmt.Sprintf("Run %s command", label), acp.ToolKindExecute, input) + allowed := e.requestPermission(id, fmt.Sprintf("Run %s command", label), acp.ToolKindExecute, input) + fixedDelay(50) + if allowed { + e.completeTool(id, map[string]any{"output": label}) + } else { + e.completeTool(id, map[string]any{"output": "denied"}) + } + } + + fixedDelay(50) + e.text("Multi-permission scenario complete.") +} + // scenarioPermissionFlow: tool requiring permission with fixed delays. func scenarioPermissionFlow(e *emitter) { fixedDelay(50) diff --git a/apps/backend/internal/task/repository/session_repository_test.go b/apps/backend/internal/task/repository/session_repository_test.go index 741039f5a8..6a4aafa36b 100644 --- a/apps/backend/internal/task/repository/session_repository_test.go +++ b/apps/backend/internal/task/repository/session_repository_test.go @@ -372,8 +372,29 @@ func TestSQLiteRepository_CompletePendingToolCallsForTurn(t *testing.T) { Type: models.MessageTypeToolCall, Metadata: map[string]interface{}{"tool_call_id": "tc-6", "status": "error"}, } + // Permission_request messages share `tool_call_id` in metadata but their `status` + // is the user's approve/reject decision, not the tool call state. The sweep must + // leave them alone — otherwise an "approved" prompt would be reset and re-shown. + approvedPermission := &models.Message{ + ID: "msg-perm-approved-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Approve this?", + Type: models.MessageTypePermissionRequest, + Metadata: map[string]interface{}{"tool_call_id": "tc-7", "pending_id": "p-1", "status": "approved"}, + } + rejectedPermission := &models.Message{ + ID: "msg-perm-rejected-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Approve this?", + Type: models.MessageTypePermissionRequest, + Metadata: map[string]interface{}{"tool_call_id": "tc-8", "pending_id": "p-2", "status": "rejected"}, + } + pendingPermission := &models.Message{ + ID: "msg-perm-pending-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Approve this?", + Type: models.MessageTypePermissionRequest, + Metadata: map[string]interface{}{"tool_call_id": "tc-9", "pending_id": "p-3", "status": "pending"}, + } - for _, msg := range []*models.Message{runningTool, completeTool, regularMsg, runningTool2, pendingTool, inProgressTool, errorTool} { + for _, msg := range []*models.Message{runningTool, completeTool, regularMsg, runningTool2, pendingTool, inProgressTool, errorTool, approvedPermission, rejectedPermission, pendingPermission} { if err := repo.CreateMessage(ctx, msg); err != nil { t.Fatalf("failed to create message %s: %v", msg.ID, err) } @@ -385,7 +406,8 @@ func TestSQLiteRepository_CompletePendingToolCallsForTurn(t *testing.T) { t.Fatalf("CompletePendingToolCallsForTurn failed: %v", err) } - // Should have updated 4 non-terminal tool call messages (running x2, pending, in_progress) + // Should have updated 4 non-terminal tool call messages (running x2, pending, in_progress). + // Permission_request messages must be excluded even though they carry tool_call_id. if affected != 4 { t.Errorf("expected 4 affected rows, got %d", affected) } @@ -430,6 +452,21 @@ func TestSQLiteRepository_CompletePendingToolCallsForTurn(t *testing.T) { t.Errorf("expected msg-error-1 status 'error' (unchanged), got %v", msg7.Metadata["status"]) } + // Verify permission_request messages were NOT affected — their status carries the + // user's decision (approve/reject) which the sweep must never overwrite. + msgPermApproved, _ := repo.GetMessage(ctx, "msg-perm-approved-1") + if msgPermApproved.Metadata["status"] != "approved" { + t.Errorf("expected msg-perm-approved-1 status 'approved' (unchanged), got %v", msgPermApproved.Metadata["status"]) + } + msgPermRejected, _ := repo.GetMessage(ctx, "msg-perm-rejected-1") + if msgPermRejected.Metadata["status"] != "rejected" { + t.Errorf("expected msg-perm-rejected-1 status 'rejected' (unchanged), got %v", msgPermRejected.Metadata["status"]) + } + msgPermPending, _ := repo.GetMessage(ctx, "msg-perm-pending-1") + if msgPermPending.Metadata["status"] != "pending" { + t.Errorf("expected msg-perm-pending-1 status 'pending' (unchanged), got %v", msgPermPending.Metadata["status"]) + } + // Running again should affect 0 rows affected2, err := repo.CompletePendingToolCallsForTurn(ctx, turnID) if err != nil { @@ -440,6 +477,64 @@ func TestSQLiteRepository_CompletePendingToolCallsForTurn(t *testing.T) { } } +// TestSQLiteRepository_GetMessageByToolCallID_ExcludesPermissionRequest locks +// in the second half of the approval-reappear fix: a tool_call message and a +// permission_request message can both carry the same tool_call_id (the +// permission_request stores it for FE pairing). The lookup must always return +// the tool_call row — otherwise a downstream tool_update would land on the +// permission_request and overwrite the user's approve/reject decision. +func TestSQLiteRepository_GetMessageByToolCallID_ExcludesPermissionRequest(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + workflow := &models.Workflow{ID: "wf-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-1") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-1") + + // Permission_request created FIRST so an ORDER BY created_at ASC sweep + // without the type filter would race to it. The fix must ignore type + // ordering and always return the tool_call row. + permMsg := &models.Message{ + ID: "msg-perm-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Approve?", + Type: models.MessageTypePermissionRequest, + Metadata: map[string]interface{}{"tool_call_id": "tc-1", "pending_id": "p-1", "status": "pending"}, + } + if err := repo.CreateMessage(ctx, permMsg); err != nil { + t.Fatalf("create permission message: %v", err) + } + toolMsg := &models.Message{ + ID: "msg-tool-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Tool", + Type: models.MessageTypeToolCall, + Metadata: map[string]interface{}{"tool_call_id": "tc-1", "status": "running"}, + } + if err := repo.CreateMessage(ctx, toolMsg); err != nil { + t.Fatalf("create tool_call message: %v", err) + } + + got, err := repo.GetMessageByToolCallID(ctx, sessionID, "tc-1") + if err != nil { + t.Fatalf("GetMessageByToolCallID: %v", err) + } + if got.Type != models.MessageTypeToolCall { + t.Errorf("expected tool_call row, got type=%s id=%s", got.Type, got.ID) + } + if got.ID != toolMsg.ID { + t.Errorf("expected msg-tool-1, got %s", got.ID) + } + + // And when only the permission_request exists, the lookup must return an + // error (not match the permission_request as a fallback). + if _, err := repo.GetMessageByToolCallID(ctx, sessionID, "tc-not-here"); err == nil { + t.Error("expected error for missing tool_call_id, got nil") + } +} + // TestGetPrimarySessionInfoByTaskIDs_PopulatesID locks in the SQL fix: the // SELECT now includes ts.id so session.ID is populated on the returned // TaskSession. Before the fix, publishTaskEvent saw sessionInfo.ID == "" and diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 815ebc1a3e..77db8a51db 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -297,8 +297,14 @@ func (r *Repository) getMessageByMetadataField(ctx context.Context, sessionID, f // GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata. // Searches all message types that have a tool_call_id in metadata (tool_call, tool_read, tool_edit, // tool_execute, tool_search, todo, etc.). +// +// Permission_request messages also store tool_call_id but represent the user-approval card, +// not the tool call itself. Excluded so a tool_update never lands on a permission_request +// row — that would overwrite metadata.status (the user's approve/reject) and retype it to +// tool_execute, making the prompt buttons reappear after the turn ends. func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { - return r.getMessageByMetadataField(ctx, sessionID, "tool_call_id", toolCallID, "ORDER BY created_at ASC LIMIT 1") + return r.getMessageByMetadataField(ctx, sessionID, "tool_call_id", toolCallID, + "AND type != 'permission_request' ORDER BY created_at ASC LIMIT 1") } // GetMessageByPendingID retrieves a message by session ID and pending_id in metadata @@ -335,12 +341,17 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin // CompletePendingToolCallsForTurn marks all non-terminal tool call messages for a turn as "complete". // This is a safety net to ensure no tool calls remain stuck in a non-terminal state (pending, // running, in_progress, etc.) after a turn completes. +// +// Excludes permission_request messages: they share `tool_call_id` in metadata but their +// `status` is the user's approve/reject decision, not the tool call state. Forcing them to +// "complete" wipes "approved"/"rejected" and re-shows the prompt buttons in the UI. func (r *Repository) CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) { drv := r.db.DriverName() query := fmt.Sprintf(` UPDATE task_session_messages SET metadata = %s WHERE turn_id = ? + AND type != 'permission_request' AND %s NOT IN ('complete', 'error') AND %s `, dialect.JSONSet(drv, "metadata", "status", "complete"), diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 14d49e753b..8be14b9867 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -462,7 +462,22 @@ func (s *Service) createToolCallMessageFallback(ctx context.Context, sessionID, } // applyToolCallMessageUpdate applies status, result, normalized data, and title to a tool call message. +// +// Defensive guard: a permission_request row also carries `tool_call_id` in metadata, so +// any future code path that hands such a row in here would otherwise silently overwrite +// the user's approve/reject decision and retype it to tool_execute. The repository's +// GetMessageByToolCallID excludes permission_request, but the guard makes the invariant +// explicit at the layer that does the writing. func (s *Service) applyToolCallMessageUpdate(message *models.Message, status, result, title string, normalized *streams.NormalizedPayload) { + if message.Type == models.MessageTypePermissionRequest { + // Error severity: the repo-layer GetMessageByToolCallID filter is supposed + // to make this branch unreachable. Reaching it means a caller bug; surface + // it loudly so the invariant violation isn't silently swallowed. + s.logger.Error("applyToolCallMessageUpdate refusing to overwrite permission_request", + zap.String("message_id", message.ID), + zap.String("incoming_status", status)) + return + } if message.Metadata == nil { message.Metadata = make(map[string]interface{}) } diff --git a/apps/web/components/task/chat/messages/permission-action-row.tsx b/apps/web/components/task/chat/messages/permission-action-row.tsx index ee3b60501c..7236562e26 100644 --- a/apps/web/components/task/chat/messages/permission-action-row.tsx +++ b/apps/web/components/task/chat/messages/permission-action-row.tsx @@ -17,7 +17,10 @@ export const PermissionActionRow = memo(function PermissionActionRow({ isResponding = false, }: PermissionActionRowProps) { return ( -