Files
kandev/apps/backend/internal/task/repository/sqlite/message.go
T
José Almeida fcc7ceb8b6 fix(approvals): drop stuck pending permission_request messages (#723)
* fix(web): exclude permission_request from turn-complete safety net

The session.turn.completed handler iterated over messages and forced
metadata.status="complete" on anything carrying tool_call_id. Permission
prompts also carry tool_call_id (for FE pairing with their tool_call),
so this loop overwrote the user's "approved" decision on every prompt
in the turn — and the FE component re-rendered the Approve/Deny buttons.

* fix(backend): protect permission_request rows from tool_call sweeps

Three permission_request invariants the prior code violated, mirroring
the FE bug fix:

- CompletePendingToolCallsForTurn: SQL UPDATE filtered by tool_call_id
  presence, also catching permission_request rows. Add type filter so
  the safety net only touches actual tool messages.
- GetMessageByToolCallID: returned the oldest row carrying that
  tool_call_id, which races to the permission_request when timing
  shifts. Exclude permission_request type so a tool_update can never
  land on the user's approve/reject row.
- applyToolCallMessageUpdate: defensive guard that refuses to overwrite
  a permission_request, making the invariant explicit at the layer that
  does the writing.

Adds a regression test for CompletePendingToolCallsForTurn covering
approved/rejected/pending permission rows.

* test(e2e): cover approval-reappear regression with mock-agent scenario

Adds a multi-permission mock-agent scenario (three sequential bash
tools, each requiring approval) and an E2E test that approves all
three and asserts no permission action row remains visible after
the agent's turn ends, and that the resolved state survives a reload.

Supporting test infrastructure:
- data-testid hooks on PermissionActionRow.
- permissionActionRows() / permissionApproveButtons() on SessionPage.
- backend fixture force-disables AGENTCTL_AUTO_APPROVE_PERMISSIONS=false
  so a developer's local AUTO_APPROVE=true env doesn't silently bypass
  the permission UI in tests that exercise the approve/reject flow.

* test(backend): cover GetMessageByToolCallID permission_request exclusion

Addresses claude-review feedback on the approvals fix:
- Adds a regression test that seeds both a permission_request and a
  tool_call with the same tool_call_id (permission_request created
  first, so an ORDER BY created_at ASC sweep without the type filter
  would race to it) and asserts the lookup returns the tool_call row.
- Bumps the applyToolCallMessageUpdate invariant guard from Warn to
  Error: reaching that branch means a caller bug, not a transient
  condition, and Warn would silently swallow the violation.

* fix(e2e): scope auto-approve override to permission-approval test only

The previous fixture force-disabled AGENTCTL_AUTO_APPROVE_PERMISSIONS
globally, which broke session-isolation.spec.ts and other tests that
use scenarios with permission-required tools (e.g. /e2e:read-and-edit)
and rely on auto-approve to let the agent finish without UI clicks.

Default the env to "true" in the fixture; let permission-approval.spec
flip it to "false" via a backend.restart() override in beforeAll, then
restore in afterAll. Adds an envOverrides parameter to backend.restart.
2026-05-01 00:25:51 +01:00

395 lines
14 KiB
Go

package sqlite
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agentctl/tracing"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
// Message operations
// CreateMessage creates a new message
func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) error {
if message.ID == "" {
message.ID = uuid.New().String()
}
if message.CreatedAt.IsZero() {
message.CreatedAt = time.Now().UTC()
}
if message.AuthorType == "" {
message.AuthorType = models.MessageAuthorUser
}
requestsInput := 0
if message.RequestsInput {
requestsInput = 1
}
messageType := string(message.Type)
if messageType == "" {
messageType = string(models.MessageTypeMessage)
}
metadataJSON := "{}"
if message.Metadata != nil {
metadataBytes, err := json.Marshal(message.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize message metadata: %w", err)
}
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt)
return err
}
// GetMessage retrieves a message by ID
func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message, error) {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE id = ?
`), id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil {
return nil, fmt.Errorf("failed to deserialize message metadata: %w", err)
}
}
return message, nil
}
// ListMessages returns all messages for a session ordered by creation time.
func (r *Repository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? ORDER BY created_at ASC
`), sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.Message
for rows.Next() {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil {
return nil, fmt.Errorf("failed to deserialize message metadata: %w", err)
}
}
result = append(result, message)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// ListMessagesPaginated returns messages for a session ordered by creation time with pagination.
func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListMessagesPaginated")
defer span.End()
limit := opts.Limit
if limit < 0 {
limit = 0
}
sortDir := "ASC"
if strings.EqualFold(opts.Sort, "desc") {
sortDir = "DESC"
}
cursor, err := r.resolveMessageCursor(ctx, sessionID, opts)
if err != nil {
return nil, false, err
}
query, args := buildListMessagesQuery(sessionID, opts, cursor, sortDir, limit)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, false, err
}
defer func() { _ = rows.Close() }()
return scanMessageRows(rows, limit)
}
func (r *Repository) resolveMessageCursor(ctx context.Context, sessionID string, opts models.ListMessagesOptions) (*models.Message, error) {
if opts.Before != "" {
cursor, err := r.GetMessage(ctx, opts.Before)
if err != nil {
return nil, err
}
if cursor.TaskSessionID != sessionID {
return nil, fmt.Errorf("message cursor not found: %s", opts.Before)
}
return cursor, nil
}
if opts.After != "" {
cursor, err := r.GetMessage(ctx, opts.After)
if err != nil {
return nil, err
}
if cursor.TaskSessionID != sessionID {
return nil, fmt.Errorf("message cursor not found: %s", opts.After)
}
return cursor, nil
}
return nil, nil
}
func buildListMessagesQuery(sessionID string, opts models.ListMessagesOptions, cursor *models.Message, sortDir string, limit int) (string, []interface{}) {
query := `
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ?`
args := []interface{}{sessionID}
if cursor != nil {
if opts.Before != "" {
query += " AND (created_at < ? OR (created_at = ? AND id < ?))"
} else if opts.After != "" {
query += " AND (created_at > ? OR (created_at = ? AND id > ?))"
}
args = append(args, cursor.CreatedAt, cursor.CreatedAt, cursor.ID)
}
query += fmt.Sprintf(" ORDER BY created_at %s, id %s", sortDir, sortDir)
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit+1)
}
return query, args
}
func scanMessageRows(rows interface {
Next() bool
Scan(dest ...any) error
Err() error
}, limit int) ([]*models.Message, bool, error) {
var result []*models.Message
for rows.Next() {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
if err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt); err != nil {
return nil, false, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil {
return nil, false, fmt.Errorf("failed to deserialize message metadata: %w", err)
}
}
result = append(result, message)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if limit > 0 && len(result) > limit {
return result[:limit], true, nil
}
return result, false, nil
}
// SearchMessages returns messages in a session whose content matches the query
// (case-insensitive substring). Newest-first ordering. Limit is capped.
func (r *Repository) SearchMessages(ctx context.Context, sessionID string, opts models.SearchMessagesOptions) ([]*models.Message, error) {
query := strings.TrimSpace(opts.Query)
if query == "" {
return nil, nil
}
limit := opts.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
like := dialect.Like(r.ro.DriverName())
// Escape LIKE metacharacters so a user query of "%" or "_" matches
// literally rather than as SQL wildcards.
escaper := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
pattern := "%" + escaper.Replace(query) + "%"
sql := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages
WHERE task_session_id = ? AND content %s ? ESCAPE '\'
ORDER BY created_at DESC, id DESC
LIMIT ?
`, like)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(sql), sessionID, pattern, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result, _, err := scanMessageRows(rows, 0)
return result, err
}
// DeleteMessage deletes a message by ID
func (r *Repository) DeleteMessage(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_messages WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("message not found: %s", id)
}
return nil
}
func (r *Repository) getMessageByMetadataField(ctx context.Context, sessionID, fieldName, fieldValue, orderSuffix string) (*models.Message, error) {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
drv := r.ro.DriverName()
query := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND %s = ?
%s
`, dialect.JSONExtract(drv, "metadata", fieldName), orderSuffix)
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), sessionID, fieldValue).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" {
_ = json.Unmarshal([]byte(metadataJSON), &message.Metadata)
}
return message, nil
}
// 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,
"AND type != 'permission_request' ORDER BY created_at ASC LIMIT 1")
}
// GetMessageByPendingID retrieves a message by session ID and pending_id in metadata
func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) {
return r.getMessageByMetadataField(ctx, sessionID, "pending_id", pendingID, "")
}
// FindMessageByPendingID finds a message by pending_id alone (without requiring session ID).
// This is useful when we only have the pending ID (e.g., from expired clarification responses).
func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
drv := r.ro.DriverName()
query := fmt.Sprintf(`
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE %s = ?
ORDER BY created_at DESC LIMIT 1
`, dialect.JSONExtract(drv, "metadata", "pending_id"))
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" {
_ = json.Unmarshal([]byte(metadataJSON), &message.Metadata)
}
return message, nil
}
// 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"),
dialect.JSONExtract(drv, "metadata", "status"),
dialect.JSONExtractIsNotNull(drv, "metadata", "tool_call_id"))
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), turnID)
if err != nil {
return 0, fmt.Errorf("failed to complete pending tool calls for turn %s: %w", turnID, err)
}
rows, _ := result.RowsAffected()
return rows, nil
}
// UpdateMessage updates an existing message
func (r *Repository) UpdateMessage(ctx context.Context, message *models.Message) error {
metadataJSON, err := json.Marshal(message.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
requestsInput := 0
if message.RequestsInput {
requestsInput = 1
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ?
WHERE id = ?
`), message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("message not found: %s", message.ID)
}
return nil
}