fcc7ceb8b6
* 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.
395 lines
14 KiB
Go
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
|
|
}
|