From 1135dabbd44af13d1edbccb8b4332ba6d2472126 Mon Sep 17 00:00:00 2001 From: ethanoroshiba Date: Wed, 5 Nov 2025 15:13:57 -0600 Subject: [PATCH 1/4] initial vibed implementation --- argo/tim-api/values-dev.yaml | 3 + charts/tim-api/values.yaml | 3 + .../api/thread/v1alpha1/thread_service.proto | 57 ++ tim-api/.env.example | 6 + tim-api/internal/config/config.go | 11 + .../internal/services/thread/checkpoint.go | 640 ++++++++++++++++++ .../services/thread/message_handlers.go | 227 +++++++ tim-cli-v2/README.md | 17 + tim-cli-v2/internal/client/client.go | 17 + tim-cli-v2/internal/tui/model.go | 359 +++++++++- tim-db/gen/db/checkpoint.sql.go | 555 +++++++++++++++ tim-db/gen/db/models.go | 27 + tim-db/gen/db/thread.sql.go | 59 +- tim-db/gen/db/tool_execution.sql.go | 21 +- ...251105000000_create_thread_checkpoints.sql | 69 ++ ...105000001_add_thread_working_directory.sql | 22 + ...05204430_add_deleted_at_to_llm_message.sql | 24 + tim-db/queries/checkpoint.sql | 157 +++++ tim-db/queries/thread.sql | 19 +- tim-db/queries/tool_execution.sql | 18 +- tim-proto/gen/openapi.yaml | 121 ++++ .../api/thread/v1alpha1/thread_service.pb.go | 237 +++++-- .../v1alpha1/thread_service.swagger.json | 107 +++ .../thread_service.connect.go | 82 ++- 24 files changed, 2754 insertions(+), 104 deletions(-) create mode 100644 tim-api/internal/services/thread/checkpoint.go create mode 100644 tim-db/gen/db/checkpoint.sql.go create mode 100644 tim-db/migrations/20251105000000_create_thread_checkpoints.sql create mode 100644 tim-db/migrations/20251105000001_add_thread_working_directory.sql create mode 100644 tim-db/migrations/20251105204430_add_deleted_at_to_llm_message.sql create mode 100644 tim-db/queries/checkpoint.sql diff --git a/argo/tim-api/values-dev.yaml b/argo/tim-api/values-dev.yaml index 8c221b21e..f01f58f44 100644 --- a/argo/tim-api/values-dev.yaml +++ b/argo/tim-api/values-dev.yaml @@ -13,6 +13,9 @@ env: TIM_API_CORS_ENABLED: "false" TIM_API_PIPEDREAM_CLIENT_ID: "HhrH_HzIBvMdW2rnNpMxkcrK7wiYKscYBzgrLu0jRiY" TIM_API_PIPEDREAM_PROJECT_ID: "proj_1jsxmER" + # Thread Checkpoints - always enabled, uses gitignore patterns, always skips binaries + TIM_API_CHECKPOINT_MAX_DIR_SIZE_MB: "100" + TIM_API_CHECKPOINT_MAX_FILE_SIZE_MB: "10" # Gateway API configuration gateway: diff --git a/charts/tim-api/values.yaml b/charts/tim-api/values.yaml index 5193a2187..fc5991e55 100644 --- a/charts/tim-api/values.yaml +++ b/charts/tim-api/values.yaml @@ -41,6 +41,9 @@ env: TIM_API_ANALYTICS_ENABLED: "true" TIM_API_ANALYTICS_BATCH_SIZE: "250" TIM_API_ANALYTICS_FLUSH_INTERVAL_SECS: "5" + # Thread Checkpoints - always enabled, uses gitignore patterns, always skips binaries + TIM_API_CHECKPOINT_MAX_DIR_SIZE_MB: "100" + TIM_API_CHECKPOINT_MAX_FILE_SIZE_MB: "10" configMap: {} # Example configuration: diff --git a/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto b/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto index fa0945f60..3d8c4f5f4 100644 --- a/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto +++ b/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto @@ -7,6 +7,7 @@ import "buf/validate/validate.proto"; import "google/api/annotations.proto"; import "google/api/client.proto"; import "google/api/field_behavior.proto"; +import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; import "tim/api/thread/v1alpha1/thread_types.proto"; import "tim/api/tool/v1alpha1/tool_types.proto"; @@ -80,6 +81,24 @@ service ThreadService { }; option (google.api.method_signature) = "parent,user_message"; } + + // Edit a thread message (with checkpoint restoration support) + rpc EditThreadMessage(EditThreadMessageRequest) returns (LlmMessage) { + option (google.api.http) = { + post: "/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}:edit" + body: "*" + }; + option (google.api.method_signature) = "path,content"; + } + + // Configure the working directory for a thread (for checkpoint creation) + rpc ConfigureThreadWorkingDirectory(ConfigureThreadWorkingDirectoryRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: "/v1alpha1/{path=orgs/*/users/*/threads/*}:configureWorkingDirectory" + body: "*" + }; + option (google.api.method_signature) = "path,working_directory"; + } } // GetThreadRequest is used to get a specific thread by its UID @@ -287,3 +306,41 @@ message UserMessage { (buf.validate.field).string.max_len = 32768 ]; } + +// EditThreadMessageRequest is used to edit a message (e.g., edit user message content) +message EditThreadMessageRequest { + // The resource path of the message being updated. + string path = 1 [ + (google.api.field_behavior) = REQUIRED, + (aep.api.field_info).resource_reference = "tim.settlerlabs.com/llm-message", + (buf.validate.field).string.pattern = "^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}/messages/[a-fA-F0-9-]{36}$" + ]; + + // The new content for the message + string content = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + + // User confirmed checkpoint restoration (required if checkpoint exists with files) + // If not provided and checkpoint restoration is needed, an error will be returned + // with details about what will be restored. + bool confirm_restore = 3; +} + +// ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation +message ConfigureThreadWorkingDirectoryRequest { + // The resource path of the thread + string path = 1 [ + (google.api.field_behavior) = REQUIRED, + (aep.api.field_info).resource_reference = "tim.settlerlabs.com/thread", + (buf.validate.field).string.pattern = "^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$" + ]; + + // The working directory path + string working_directory = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; +} diff --git a/tim-api/.env.example b/tim-api/.env.example index 7775dfb52..1909db665 100644 --- a/tim-api/.env.example +++ b/tim-api/.env.example @@ -54,3 +54,9 @@ STRIPE_WEBHOOK_SECRET=from_stripe_dashboard STRIPE_SUCCESS_URL=http://localhost:3000/billing/success STRIPE_CANCEL_URL=http://localhost:3000/billing/cancel STRIPE_BILLING_PORTAL_URL=http://localhost:3000/billing + +# Thread Checkpoints +# Maximum total directory size in MB (default: 100) +TIM_API_CHECKPOINT_MAX_DIR_SIZE_MB=100 +# Maximum individual file size in MB (default: 10) +TIM_API_CHECKPOINT_MAX_FILE_SIZE_MB=10 diff --git a/tim-api/internal/config/config.go b/tim-api/internal/config/config.go index a4c66c0d4..658921bf6 100644 --- a/tim-api/internal/config/config.go +++ b/tim-api/internal/config/config.go @@ -27,6 +27,7 @@ type Config struct { Pipedream pipedream.Config Analytics AnalyticsConfig Stripe StripeConfig + Checkpoint CheckpointConfig } // BaseServerConfig holds base server-specific configuration @@ -95,6 +96,12 @@ type StripeConfig struct { BillingPortalURL string } +// CheckpointConfig holds checkpoint configuration +type CheckpointConfig struct { + MaxDirectorySize int64 // Maximum total directory size in bytes (default: 100MB) + MaxFileSize int64 // Maximum individual file size in bytes (default: 10MB) +} + // Load loads configuration from environment variables only func Load() (*Config, error) { devMode := getBoolEnv("TIM_API_DEV_MODE", true) @@ -167,6 +174,10 @@ func Load() (*Config, error) { CancelURL: getEnv("STRIPE_CANCEL_URL", "http://localhost:3000/billing/cancel"), BillingPortalURL: getEnv("STRIPE_BILLING_PORTAL_URL", "http://localhost:3000/billing"), }, + Checkpoint: CheckpointConfig{ + MaxDirectorySize: int64(getIntEnv("TIM_API_CHECKPOINT_MAX_DIR_SIZE_MB", 100)) * 1024 * 1024, + MaxFileSize: int64(getIntEnv("TIM_API_CHECKPOINT_MAX_FILE_SIZE_MB", 10)) * 1024 * 1024, + }, } return cfg, nil diff --git a/tim-api/internal/services/thread/checkpoint.go b/tim-api/internal/services/thread/checkpoint.go new file mode 100644 index 000000000..b74e133fb --- /dev/null +++ b/tim-api/internal/services/thread/checkpoint.go @@ -0,0 +1,640 @@ +package thread + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Greybox-Labs/tim/shared/llm" + "github.com/Greybox-Labs/tim/tim-api/internal/database" + "github.com/Greybox-Labs/tim/tim-api/internal/pgnotifier" + "github.com/Greybox-Labs/tim/tim-api/internal/resourcepath" + "github.com/Greybox-Labs/tim/tim-db/gen/db" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const ( + // MaxDirectorySize is the maximum total size of files to checkpoint (100MB) + MaxDirectorySize = 100 * 1024 * 1024 + // MaxFileSize is the maximum individual file size to checkpoint (10MB) + MaxFileSize = 10 * 1024 * 1024 + // CheckpointTimeout is the maximum time to spend creating a checkpoint + CheckpointTimeout = 30 * time.Second +) + +// CheckpointConfig holds configuration for checkpoint creation +type CheckpointConfig struct { + MaxDirectorySize int64 + MaxFileSize int64 +} + +// DefaultCheckpointConfig returns the default checkpoint configuration +func DefaultCheckpointConfig() *CheckpointConfig { + return &CheckpointConfig{ + MaxDirectorySize: MaxDirectorySize, + MaxFileSize: MaxFileSize, + } +} + +// FileSnapshot represents a file's state at a checkpoint +type FileSnapshot struct { + FilePath string + Content []byte + Hash string + Size int64 + Mode os.FileMode + ModifiedTime time.Time + IsDeleted bool +} + +// DiffData represents the JSONB format for storing line-based diffs +type DiffData struct { + Lines map[string]string `json:"lines"` // Line number -> new content + Deleted []int `json:"deleted,omitempty"` // Deleted line numbers +} + +// CreateCheckpoint creates a checkpoint for the given thread and message +// This is called by the worker after a message is completed with no tool calls +func (s *Service) CreateCheckpoint(ctx context.Context, threadUID uuid.UUID, messageUID uuid.UUID, workingDir string) error { + if workingDir == "" { + s.logger.Debugw("skipping checkpoint creation: no working directory", "thread_uid", threadUID) + return nil + } + + startTime := time.Now() + s.logger.Infow("creating checkpoint", "thread_uid", threadUID, "message_uid", messageUID, "working_dir", workingDir) + + // Create a timeout context for checkpoint creation + timeoutCtx, cancel := context.WithTimeout(ctx, CheckpointTimeout) + defer cancel() + + config := DefaultCheckpointConfig() + + // Scan directory and collect file snapshots + snapshots, totalSize, err := s.scanDirectory(timeoutCtx, workingDir, config) + if err != nil { + s.logger.Errorw("failed to scan directory for checkpoint", "error", err, "thread_uid", threadUID) + return fmt.Errorf("failed to scan directory: %w", err) + } + + if totalSize > config.MaxDirectorySize { + s.logger.Warnw("directory too large for checkpoint", + "size", totalSize, + "max_size", config.MaxDirectorySize, + "thread_uid", threadUID) + return nil // Skip checkpoint creation but don't error + } + + queries, err := database.Queries(ctx) + if err != nil { + return fmt.Errorf("failed to get database queries: %w", err) + } + + // Create checkpoint record + checkpoint, err := queries.CreateCheckpoint(ctx, db.CreateCheckpointParams{ + ThreadUID: threadUID, + MessageUID: messageUID, + WorkingDirectory: workingDir, + TotalFiles: int32(len(snapshots)), + TotalSizeBytes: totalSize, + }) + if err != nil { + s.logger.Errorw("failed to create checkpoint record", "error", err, "thread_uid", threadUID) + return fmt.Errorf("failed to create checkpoint: %w", err) + } + + // Store file snapshots + for _, snapshot := range snapshots { + err = s.storeFileSnapshot(ctx, queries, threadUID, checkpoint.UID, snapshot) + if err != nil { + s.logger.Errorw("failed to store file snapshot", + "error", err, + "file", snapshot.FilePath, + "checkpoint_uid", checkpoint.UID) + // Continue with other files even if one fails + continue + } + } + + duration := time.Since(startTime) + s.logger.Infow("checkpoint created successfully", + "checkpoint_uid", checkpoint.UID, + "thread_uid", threadUID, + "files", len(snapshots), + "size_bytes", totalSize, + "duration_ms", duration.Milliseconds()) + + return nil +} + +// scanDirectory walks the directory tree and collects file snapshots +func (s *Service) scanDirectory(ctx context.Context, workingDir string, config *CheckpointConfig) ([]*FileSnapshot, int64, error) { + var snapshots []*FileSnapshot + var totalSize int64 + + // Load gitignore patterns - this is the only source of ignore patterns + gitignorePatterns := s.loadGitignorePatterns(workingDir) + + err := filepath.Walk(workingDir, func(path string, info os.FileInfo, err error) error { + // Check context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err != nil { + s.logger.Warnw("error walking path", "path", path, "error", err) + return nil // Continue walking + } + + // Skip directories + if info.IsDir() { + // Check if directory should be ignored + relPath, _ := filepath.Rel(workingDir, path) + if s.shouldIgnore(relPath, gitignorePatterns, true) { + return filepath.SkipDir + } + return nil + } + + // Get relative path + relPath, err := filepath.Rel(workingDir, path) + if err != nil { + s.logger.Warnw("failed to get relative path", "path", path, "error", err) + return nil + } + + // Check if file should be ignored based on gitignore + if s.shouldIgnore(relPath, gitignorePatterns, false) { + return nil + } + + // Check file size + if info.Size() > config.MaxFileSize { + s.logger.Debugw("skipping large file", "file", relPath, "size", info.Size()) + return nil + } + + // Read file content + content, err := os.ReadFile(path) + if err != nil { + s.logger.Warnw("failed to read file", "file", relPath, "error", err) + return nil + } + + // Always skip binary files + if s.isBinaryFile(content) { + s.logger.Debugw("skipping binary file", "file", relPath) + return nil + } + + // Calculate hash + hash := s.calculateHash(content) + + snapshot := &FileSnapshot{ + FilePath: relPath, + Content: content, + Hash: hash, + Size: info.Size(), + Mode: info.Mode(), + ModifiedTime: info.ModTime(), + IsDeleted: false, + } + + snapshots = append(snapshots, snapshot) + totalSize += info.Size() + + return nil + }) + + if err != nil { + return nil, 0, fmt.Errorf("failed to walk directory: %w", err) + } + + return snapshots, totalSize, nil +} + +// storeFileSnapshot stores a file snapshot in the database +func (s *Service) storeFileSnapshot(ctx context.Context, queries *db.Queries, threadUID uuid.UUID, checkpointUID uuid.UUID, snapshot *FileSnapshot) error { + // Check if there's a previous snapshot for this file + lastSnapshot, err := queries.GetLastFileSnapshot(ctx, db.GetLastFileSnapshotParams{ + ThreadUID: threadUID, + FilePath: snapshot.FilePath, + }) + + isBaseSnapshot := false + var contentJSON []byte + + if err != nil { + // No previous snapshot exists, this is a base snapshot + isBaseSnapshot = true + contentJSON, err = s.encodeBaseSnapshot(snapshot.Content) + if err != nil { + return fmt.Errorf("failed to encode base snapshot: %w", err) + } + } else { + // Check if file has changed + if lastSnapshot.FileHash.String == snapshot.Hash { + // File hasn't changed, skip storing + return nil + } + + // Generate diff from last snapshot + diff, err := s.generateDiff(ctx, queries, threadUID, lastSnapshot.CheckpointUid, snapshot) + if err != nil { + s.logger.Warnw("failed to generate diff, storing as base snapshot", + "file", snapshot.FilePath, + "error", err) + // Fall back to base snapshot + isBaseSnapshot = true + contentJSON, err = s.encodeBaseSnapshot(snapshot.Content) + if err != nil { + return fmt.Errorf("failed to encode base snapshot: %w", err) + } + } else { + contentJSON = diff + } + } + + // Store in database + _, err = queries.CreateFileSnapshot(ctx, db.CreateFileSnapshotParams{ + CheckpointUid: checkpointUID, + FilePath: snapshot.FilePath, + IsBaseSnapshot: isBaseSnapshot, + Content: contentJSON, + FileHash: pgtype.Text{ + String: snapshot.Hash, + Valid: true, + }, + FileSize: pgtype.Int8{ + Int64: snapshot.Size, + Valid: true, + }, + FileMode: pgtype.Int4{ + Int32: int32(snapshot.Mode), + Valid: true, + }, + ModifiedTime: pgtype.Timestamptz{ + Time: snapshot.ModifiedTime, + Valid: true, + }, + IsDeleted: snapshot.IsDeleted, + }) + + return err +} + +// encodeBaseSnapshot encodes file content as a base snapshot (array of lines) +func (s *Service) encodeBaseSnapshot(content []byte) ([]byte, error) { + lines := strings.Split(string(content), "\n") + return json.Marshal(map[string]interface{}{ + "lines": lines, + }) +} + +// generateDiff generates a compact line-based diff between two snapshots +func (s *Service) generateDiff(ctx context.Context, queries *db.Queries, threadUID uuid.UUID, lastCheckpointUid uuid.UUID, newSnapshot *FileSnapshot) ([]byte, error) { + // Reconstruct the old content + oldContent, err := s.reconstructFileContent(ctx, queries, threadUID, lastCheckpointUid, newSnapshot.FilePath) + if err != nil { + return nil, fmt.Errorf("failed to reconstruct old content: %w", err) + } + + // Split into lines + oldLines := strings.Split(string(oldContent), "\n") + newLines := strings.Split(string(newSnapshot.Content), "\n") + + // Compute line-based diff + diff := s.computeLineDiff(oldLines, newLines) + + return json.Marshal(diff) +} + +// computeLineDiff computes a compact line-based diff +func (s *Service) computeLineDiff(oldLines, newLines []string) *DiffData { + diff := &DiffData{ + Lines: make(map[string]string), + Deleted: []int{}, + } + + maxLen := len(oldLines) + if len(newLines) > maxLen { + maxLen = len(newLines) + } + + for i := 0; i < maxLen; i++ { + if i >= len(oldLines) { + // New line added + diff.Lines[fmt.Sprintf("%d", i)] = newLines[i] + } else if i >= len(newLines) { + // Line deleted + diff.Deleted = append(diff.Deleted, i) + } else if oldLines[i] != newLines[i] { + // Line changed + diff.Lines[fmt.Sprintf("%d", i)] = newLines[i] + } + } + + return diff +} + +// reconstructFileContent reconstructs file content at a given checkpoint +func (s *Service) reconstructFileContent(ctx context.Context, queries *db.Queries, threadUID uuid.UUID, checkpointUID uuid.UUID, filePath string) ([]byte, error) { + // Get the base snapshot for this file + baseSnapshot, err := queries.GetBaseSnapshotForFile(ctx, db.GetBaseSnapshotForFileParams{ + ThreadUID: threadUID, + FilePath: filePath, + CheckpointUid: checkpointUID, + }) + if err != nil { + return nil, fmt.Errorf("failed to get base snapshot: %w", err) + } + + // Decode base content + var baseData map[string]interface{} + err = json.Unmarshal(baseSnapshot.Content, &baseData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal base snapshot: %w", err) + } + + linesInterface, ok := baseData["lines"].([]interface{}) + if !ok { + return nil, errors.New("invalid base snapshot format") + } + + lines := make([]string, len(linesInterface)) + for i, line := range linesInterface { + lines[i] = line.(string) + } + + // Get checkpoint time for base snapshot + baseCheckpoint, err := queries.GetCheckpoint(ctx, baseSnapshot.CheckpointUid) + if err != nil { + return nil, fmt.Errorf("failed to get base checkpoint: %w", err) + } + + targetCheckpoint, err := queries.GetCheckpoint(ctx, checkpointUID) + if err != nil { + return nil, fmt.Errorf("failed to get target checkpoint: %w", err) + } + + // Get all diffs between base and target + diffs, err := queries.ListDiffSnapshotsForFile(ctx, db.ListDiffSnapshotsForFileParams{ + ThreadUID: threadUID, + FilePath: filePath, + BaseCheckpointTime: baseCheckpoint.CreateTime, + TargetCheckpointTime: targetCheckpoint.CreateTime, + }) + if err != nil { + return nil, fmt.Errorf("failed to list diff snapshots: %w", err) + } + + // Apply diffs in order + for _, diff := range diffs { + lines, err = s.applyLineDiff(lines, diff.Content) + if err != nil { + return nil, fmt.Errorf("failed to apply diff: %w", err) + } + } + + return []byte(strings.Join(lines, "\n")), nil +} + +// applyLineDiff applies a line-based diff to reconstruct content +func (s *Service) applyLineDiff(baseLines []string, diffJSON []byte) ([]string, error) { + var diff DiffData + err := json.Unmarshal(diffJSON, &diff) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal diff: %w", err) + } + + // Create a copy of base lines + result := make([]string, len(baseLines)) + copy(result, baseLines) + + // Apply changed lines + for lineNumStr, content := range diff.Lines { + var lineNum int + fmt.Sscanf(lineNumStr, "%d", &lineNum) + + // Extend slice if necessary + for len(result) <= lineNum { + result = append(result, "") + } + result[lineNum] = content + } + + // Remove deleted lines (in reverse order to maintain indices) + for i := len(diff.Deleted) - 1; i >= 0; i-- { + lineNum := diff.Deleted[i] + if lineNum < len(result) { + result = append(result[:lineNum], result[lineNum+1:]...) + } + } + + return result, nil +} + +// loadGitignorePatterns loads patterns from .gitignore file +func (s *Service) loadGitignorePatterns(workingDir string) []string { + gitignorePath := filepath.Join(workingDir, ".gitignore") + file, err := os.Open(gitignorePath) + if err != nil { + return nil + } + defer file.Close() + + var patterns []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, line) + } + + return patterns +} + +// shouldIgnore checks if a path should be ignored based on patterns +func (s *Service) shouldIgnore(path string, patterns []string, isDir bool) bool { + for _, pattern := range patterns { + matched, err := filepath.Match(pattern, filepath.Base(path)) + if err == nil && matched { + return true + } + + // Also check if path contains the pattern (for directories like node_modules) + if strings.Contains(path, pattern) { + return true + } + } + return false +} + +// isBinaryFile checks if content appears to be binary +func (s *Service) isBinaryFile(content []byte) bool { + // Check first 8KB for null bytes + checkSize := 8192 + if len(content) < checkSize { + checkSize = len(content) + } + + return bytes.IndexByte(content[:checkSize], 0) != -1 +} + +// calculateHash calculates SHA-256 hash of content +func (s *Service) calculateHash(content []byte) string { + hash := sha256.Sum256(content) + return hex.EncodeToString(hash[:]) +} + +// GetCheckpointForMessage retrieves checkpoint information for a message +func (s *Service) GetCheckpointForMessage(ctx context.Context, messageUID uuid.UUID) (*db.ThreadCheckpoint, []*db.CheckpointFileSnapshot, error) { + queries, err := database.Queries(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get database queries: %w", err) + } + + checkpoint, err := queries.GetCheckpointByMessage(ctx, messageUID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get checkpoint: %w", err) + } + + snapshots, err := queries.ListFileSnapshots(ctx, checkpoint.UID) + if err != nil { + return nil, nil, fmt.Errorf("failed to list file snapshots: %w", err) + } + + snapshotPtrs := make([]*db.CheckpointFileSnapshot, len(snapshots)) + for i := range snapshots { + snapshotPtrs[i] = &snapshots[i] + } + + return &checkpoint, snapshotPtrs, nil +} + +// sendFileRestorationToolCalls sends file_write tool calls for checkpoint restoration +func (s *Service) sendFileRestorationToolCalls( + ctx context.Context, + queries *db.Queries, + threadPath *resourcepath.ThreadPath, + checkpoint db.ThreadCheckpoint, + fileSnapshots []db.CheckpointFileSnapshot, +) error { + // Get the last message to determine the next index + lastMessage, err := queries.GetLastThreadMessage(ctx, threadPath.ThreadUID) + if err != nil { + s.logger.Errorw("failed to get last message", "error", err) + return fmt.Errorf("failed to get last message: %w", err) + } + + nextIdx := lastMessage.Idx + 1 + + // Create a system assistant message to hold the restoration tool calls + restorationMessage, err := queries.CreateMessage(ctx, db.CreateMessageParams{ + OriginThreadUID: threadPath.ThreadUID, + Idx: nextIdx, + Role: db.LlmMessageRoleAssistant, + StreamStatus: db.LlmMessageStreamStatusComplete, + }) + if err != nil { + s.logger.Errorw("failed to create restoration message", "error", err) + return fmt.Errorf("failed to create restoration message: %w", err) + } + + // Add the message to the thread + err = queries.AddMessageToThread(ctx, db.AddMessageToThreadParams{ + ThreadUID: threadPath.ThreadUID, + MessageUID: restorationMessage.UID, + }) + if err != nil { + s.logger.Errorw("failed to add restoration message to thread", "error", err) + return fmt.Errorf("failed to add restoration message to thread: %w", err) + } + + s.logger.Infow("created checkpoint restoration message", + "message_uid", restorationMessage.UID, + "thread_uid", threadPath.ThreadUID, + "file_count", len(fileSnapshots)) + + // Create pg_notify notifier for sending events + notifier := pgnotifier.NewThreadEventNotifier(ctx, queries, threadPath, s.logger) + + // For each file snapshot, reconstruct content and create a file_write tool call + for idx, snapshot := range fileSnapshots { + // Reconstruct file content at this checkpoint + fileContent, err := s.reconstructFileContent(ctx, queries, threadPath.ThreadUID, checkpoint.UID, snapshot.FilePath) + if err != nil { + s.logger.Warnw("failed to reconstruct file content, skipping", + "file", snapshot.FilePath, + "error", err) + continue + } + + // Create file_write tool input + toolInput := map[string]interface{}{ + "path": snapshot.FilePath, + "content": string(fileContent), + } + + toolInputJSON, err := json.Marshal(toolInput) + if err != nil { + s.logger.Errorw("failed to marshal tool input", "file", snapshot.FilePath, "error", err) + continue + } + + // Create tool use content + toolUseContent := llm.ToolUseContent{ + ID: llm.GenerateToolUseID(), + Name: string(llm.ToolFileWrite), + Input: toolInputJSON, + } + + contentJSON, err := json.Marshal(toolUseContent) + if err != nil { + s.logger.Errorw("failed to marshal tool use content", "file", snapshot.FilePath, "error", err) + continue + } + + // Create the content block for this file_write tool call + content, err := queries.CreateMessageContent(ctx, db.CreateMessageContentParams{ + MessageUID: restorationMessage.UID, + Idx: int32(idx), + Type: db.LlmMessageContentTypeToolUse, + Content: contentJSON, + StreamStatus: db.LlmMessageStreamStatusComplete, + }) + if err != nil { + s.logger.Errorw("failed to create tool use content", "file", snapshot.FilePath, "error", err) + continue + } + + s.logger.Infow("created file restoration tool call", + "file", snapshot.FilePath, + "tool_use_id", toolUseContent.ID, + "content_uid", content.UID, + "content_idx", idx) + + // Send notification for this tool call + notifier.NotifyToolCall(toolUseContent.ID) + } + + s.logger.Infow("sent all file restoration tool calls", + "checkpoint_uid", checkpoint.UID, + "file_count", len(fileSnapshots)) + + return nil +} diff --git a/tim-api/internal/services/thread/message_handlers.go b/tim-api/internal/services/thread/message_handlers.go index 1cda4a5e8..e4b345199 100644 --- a/tim-api/internal/services/thread/message_handlers.go +++ b/tim-api/internal/services/thread/message_handlers.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "connectrpc.com/connect" "github.com/Greybox-Labs/tim/shared/llm" @@ -21,6 +22,7 @@ import ( toolv1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/tool/v1alpha1" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/structpb" ) @@ -28,6 +30,207 @@ const ( MessageResourceType = "thread-message" ) +// EditThreadMessage edits a thread message (with checkpoint restoration support) +func (s *Service) EditThreadMessage( + ctx context.Context, + req *connect.Request[threadv1.EditThreadMessageRequest], +) (*connect.Response[threadv1.LlmMessage], error) { + authzHandle, err := authz.HandlerFromContext(ctx) + if err != nil { + s.logger.Errorw("failed to get authz handle from context", "error", err, "path", req.Msg.Path) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + messagePath, err := resourcepath.ParseThreadMessagePath(req.Msg.Path) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid message path")) + } + + authzHandle.SetResource(MessageResourceType, messagePath) + err = authzHandle.Authorize(ctx, "update") + if err != nil { + return nil, err + } + + queries, err := database.Queries(ctx) + if err != nil { + s.logger.Errorw("failed to get database queries", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Get the message being updated + message, err := queries.GetMessageByUID(ctx, messagePath.ThreadMessageUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("message not found")) + } + s.logger.Errorw("failed to get message", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Only user messages can be updated + if message.Role != db.LlmMessageRoleUser { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only user messages can be updated")) + } + + // Check if there's a checkpoint at this message index + checkpoint, err := queries.GetCheckpointByMessage(ctx, messagePath.ThreadMessageUID) + checkpointExists := err == nil + + var fileSnapshots []db.CheckpointFileSnapshot + if checkpointExists { + fileSnapshots, err = queries.ListFileSnapshots(ctx, checkpoint.UID) + if err != nil { + s.logger.Errorw("failed to list file snapshots", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + } + + // If checkpoint exists with files and user hasn't confirmed, return error with details + if checkpointExists && len(fileSnapshots) > 0 && !req.Msg.ConfirmRestore { + // Count messages that will be deleted (all messages after this one) + threadMessages, err := queries.ListThreadMessages(ctx, db.ListThreadMessagesParams{ + ThreadUID: message.OriginThreadUID, + PageLimit: 1000, // Large enough to get all messages + }) + if err != nil { + s.logger.Errorw("failed to list thread messages", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + messagesToDelete := 0 + for _, tm := range threadMessages { + if tm.Idx > message.Idx { + messagesToDelete++ + } + } + + // Collect affected file paths + affectedPaths := make([]string, 0, len(fileSnapshots)) + for _, snapshot := range fileSnapshots { + affectedPaths = append(affectedPaths, snapshot.FilePath) + } + + // Return error with checkpoint restoration details + errorMsg := fmt.Sprintf( + "checkpoint restoration required: %d files will be restored, %d messages will be deleted. Affected files: %v. Set confirm_restore=true to proceed.", + len(fileSnapshots), + messagesToDelete, + affectedPaths, + ) + + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New(errorMsg)) + } + + // User has confirmed or no checkpoint exists - proceed with update + // Delete all messages after this one + err = queries.DeleteMessagesAfterIndex(ctx, db.DeleteMessagesAfterIndexParams{ + ThreadUID: message.OriginThreadUID, + Idx: message.Idx, + }) + if err != nil { + s.logger.Errorw("failed to delete messages after index", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Update the message content + // First, get the content block (assuming user messages have a single text content block) + contentBlocks, err := queries.ListMessageContent(ctx, messagePath.ThreadMessageUID) + if err != nil || len(contentBlocks) == 0 { + s.logger.Errorw("failed to get message content", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get message content")) + } + + // Update the text content + textContent := llm.TextContent{ + Text: req.Msg.Content, + } + contentJSON, err := json.Marshal(textContent) + if err != nil { + s.logger.Errorw("failed to marshal content", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + err = queries.UpdateMessageContentFinal(ctx, db.UpdateMessageContentFinalParams{ + ContentUid: contentBlocks[0].UID, + Content: contentJSON, + }) + if err != nil { + s.logger.Errorw("failed to update message content", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // If checkpoint exists with files, restore them via tool execution + if checkpointExists && len(fileSnapshots) > 0 { + s.logger.Infow("checkpoint restoration required", + "checkpoint_uid", checkpoint.UID, + "file_count", len(fileSnapshots), + "thread_uid", messagePath.Parent.ThreadUID) + + // Send file restoration tool calls through the stream + if err := s.sendFileRestorationToolCalls(ctx, queries, messagePath.Parent, checkpoint, fileSnapshots); err != nil { + s.logger.Errorw("failed to send file restoration tool calls", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to restore checkpoint files")) + } + } + + // Get updated message to return + updatedMessage, err := s.GetLlmMessage(ctx, connect.NewRequest(&threadv1.GetLlmMessageRequest{ + Path: req.Msg.Path, + })) + if err != nil { + s.logger.Errorw("failed to get updated message", "error", err) + return nil, err + } + + return connect.NewResponse(updatedMessage.Msg), nil +} + +// ConfigureThreadWorkingDirectory configures the working directory for a thread (for checkpoint creation) +func (s *Service) ConfigureThreadWorkingDirectory( + ctx context.Context, + req *connect.Request[threadv1.ConfigureThreadWorkingDirectoryRequest], +) (*connect.Response[emptypb.Empty], error) { + authzHandle, err := authz.HandlerFromContext(ctx) + if err != nil { + s.logger.Errorw("failed to get authz handle from context", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + threadPath, err := resourcepath.ParseThreadPath(req.Msg.Path) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid thread path")) + } + + authzHandle.SetResource(ResourceType, threadPath) + err = authzHandle.Authorize(ctx, "update") + if err != nil { + return nil, err + } + + queries, err := database.Queries(ctx) + if err != nil { + s.logger.Errorw("failed to get database queries", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Update the thread's working directory + err = queries.UpdateThreadWorkingDirectory(ctx, db.UpdateThreadWorkingDirectoryParams{ + ThreadUID: threadPath.ThreadUID, + WorkingDirectory: req.Msg.WorkingDirectory, + }) + if err != nil { + s.logger.Errorw("failed to update thread working directory", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + s.logger.Infow("updated thread working directory", + "thread_uid", threadPath.ThreadUID, + "working_directory", req.Msg.WorkingDirectory) + + return connect.NewResponse(&emptypb.Empty{}), nil +} + // GetLlmMessage retrieves an LLM message of a thread func (s *Service) GetLlmMessage( ctx context.Context, @@ -688,6 +891,30 @@ func (s *Service) SubmitUserMessage( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } + // Create checkpoint for this user message as part of the transaction + // This captures the working directory state before the LLM processes the message + // Must be synchronous and transactional to prevent race conditions with LLM job processing + workingDir, err := queries.GetThreadWorkingDirectory(ctx, thread.UID) + if err != nil { + s.logger.Warnw("failed to get thread working directory for checkpoint", + "error", err, + "thread_uid", thread.UID, + "message_uid", message.UID) + } else if workingDir.String != "" { + // Create checkpoint as part of the same transaction + if err := s.CreateCheckpoint(ctx, thread.UID, message.UID, workingDir.String); err != nil { + s.logger.Errorw("failed to create checkpoint for user message", + "error", err, + "thread_uid", thread.UID, + "message_uid", message.UID) + // Don't fail the request if checkpoint creation fails + } else { + s.logger.Infow("checkpoint created for user message", + "thread_uid", thread.UID, + "message_uid", message.UID) + } + } + // Notify about thread state change notifier := pgnotifier.NewThreadEventNotifier(ctx, queries, parentPath, s.logger) notifier.NotifyThreadStateChange(db.ThreadLlmStatusProcessing) diff --git a/tim-cli-v2/README.md b/tim-cli-v2/README.md index e5b7fe055..e05627fd2 100644 --- a/tim-cli-v2/README.md +++ b/tim-cli-v2/README.md @@ -132,9 +132,25 @@ tim-cli-v2 whoami When in interactive chat mode: - **Enter** - Send message +- **Ctrl+L** - Open message list to view and edit previous messages - **Esc / Ctrl+C** - Exit chat - **Arrow Keys** - Scroll through chat history +#### Message List Mode + +When viewing the message list (press Ctrl+L from chat): + +- **Up/Down Arrows** - Navigate through messages +- **Enter** - Edit the selected message (user messages only) +- **Esc** - Return to chat mode + +#### Editing Mode + +When editing a message: + +- **Ctrl+S** - Save changes and update the thread +- **Esc** - Cancel editing and return to message list + ## Architecture ### Components @@ -242,6 +258,7 @@ The API may not have fully implemented the streaming endpoints yet. Check: ## TODO +- [x] Message viewer and editing (Ctrl+L to view/edit messages) - [ ] Support for multiple personas - [ ] Todo list integration in TUI - [ ] Thread history browser diff --git a/tim-cli-v2/internal/client/client.go b/tim-cli-v2/internal/client/client.go index 862801db9..38f6fbeb2 100644 --- a/tim-cli-v2/internal/client/client.go +++ b/tim-cli-v2/internal/client/client.go @@ -262,6 +262,23 @@ func (c *TimAPIClient) ListPersonas(ctx context.Context) (*personav1alpha1.ListP return resp, nil } +// EditThreadMessage edits a message on a thread +func (c *TimAPIClient) EditThreadMessage(ctx context.Context, messagePath, newContent string, confirmRestore bool) (*threadv1alpha1.LlmMessage, error) { + c.debugLog("[client] EditThreadMessage: messagePath=%s, contentLen=%d, confirmRestore=%v", messagePath, len(newContent), confirmRestore) + req := connect.NewRequest(&threadv1alpha1.EditThreadMessageRequest{ + Path: messagePath, + Content: newContent, + ConfirmRestore: confirmRestore, + }) + resp, err := c.client.Thread.EditThreadMessage(ctx, req) + if err != nil { + c.debugLog("[client] EditThreadMessage failed: %v", err) + return nil, err + } + c.debugLog("[client] EditThreadMessage successful") + return resp.Msg, nil +} + // Ping tests API connectivity func (c *TimAPIClient) Ping(ctx context.Context) error { c.debugLog("[client] Ping: testing connectivity") diff --git a/tim-cli-v2/internal/tui/model.go b/tim-cli-v2/internal/tui/model.go index 17e4fed16..6c5eac78c 100644 --- a/tim-cli-v2/internal/tui/model.go +++ b/tim-cli-v2/internal/tui/model.go @@ -22,6 +22,15 @@ var ( boldTextRegex = regexp.MustCompile(`\*\*([^*]+)\*\*`) ) +// ViewMode represents the current view mode of the TUI +type ViewMode int + +const ( + ViewModeChat ViewMode = iota + ViewModeMessageList + ViewModeEditing +) + // Styles var ( titleStyle = lipgloss.NewStyle(). @@ -55,6 +64,14 @@ var ( helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) + + selectedItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")). + Bold(true). + Background(lipgloss.Color("236")) + + messageListItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) ) // Message types @@ -96,6 +113,14 @@ type ( errMsg struct { err error } + + // messagesLoadedMsg is sent when messages are fetched + messagesLoadedMsg struct { + messages []*threadv1alpha1.LlmMessage + } + + // messageEditedMsg is sent when a message is edited successfully + messageEditedMsg struct{} ) // Model represents the chat TUI state @@ -120,6 +145,14 @@ type Model struct { streamSub chan tea.Msg // Subscription channel for streaming events err error + // Message viewer state + viewMode ViewMode + llmMessages []*threadv1alpha1.LlmMessage // Full message objects from API + selectedMessageIdx int // Index of selected message in message viewer + editingMessagePath string // Path of message being edited + editingMessageOrig string // Original content before editing + messageListViewport viewport.Model // Viewport for message list + ctx context.Context cancel context.CancelFunc @@ -143,6 +176,7 @@ func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string ta.ShowLineNumbers = false vp := viewport.New(80, 20) + msgListVp := viewport.New(80, 20) ctx, cancel := context.WithCancel(context.Background()) @@ -160,6 +194,10 @@ func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string messages: []string{}, currentResponse: &strings.Builder{}, // Initialize as pointer displayedContentIDs: make(map[string]bool), + viewMode: ViewModeChat, + llmMessages: []*threadv1alpha1.LlmMessage{}, + selectedMessageIdx: 0, + messageListViewport: msgListVp, ctx: ctx, cancel: cancel, debugLogger: debugLogger, @@ -191,19 +229,80 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - m.debugLog("KeyMsg received: %s", msg.String()) - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - m.debugLog("Quit key pressed, canceling context") - m.cancel() - return m, tea.Quit - - case tea.KeyEnter: - if !m.waiting { - m.debugLog("Enter pressed, sending message") - return m, m.sendMessage() - } else { - m.debugLog("Enter pressed but waiting=true, ignoring") + m.debugLog("KeyMsg received: %s (viewMode=%d)", msg.String(), m.viewMode) + + // Handle view mode specific keys + switch m.viewMode { + case ViewModeChat: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.debugLog("Quit key pressed, canceling context") + m.cancel() + return m, tea.Quit + + case tea.KeyEnter: + if !m.waiting { + m.debugLog("Enter pressed, sending message") + return m, m.sendMessage() + } else { + m.debugLog("Enter pressed but waiting=true, ignoring") + } + + case tea.KeyCtrlL: + // Switch to message viewer mode + if m.threadID != "" { + m.debugLog("Ctrl+L pressed, switching to message viewer") + return m, m.loadMessages() + } + } + + case ViewModeMessageList: + switch msg.Type { + case tea.KeyEsc: + // Return to chat mode + m.debugLog("Esc pressed in message list, returning to chat") + m.viewMode = ViewModeChat + m.textarea.Focus() + return m, nil + + case tea.KeyUp: + if m.selectedMessageIdx > 0 { + m.selectedMessageIdx-- + m.updateMessageListViewport() + } + return m, nil + + case tea.KeyDown: + if m.selectedMessageIdx < len(m.llmMessages)-1 { + m.selectedMessageIdx++ + m.updateMessageListViewport() + } + return m, nil + + case tea.KeyEnter: + // Edit the selected message + if m.selectedMessageIdx < len(m.llmMessages) { + msg := m.llmMessages[m.selectedMessageIdx] + m.debugLog("Enter pressed on message %d, starting edit", m.selectedMessageIdx) + return m, m.startEditingMessage(msg) + } + return m, nil + } + + case ViewModeEditing: + switch msg.Type { + case tea.KeyEsc: + // Cancel editing + m.debugLog("Esc pressed while editing, canceling") + m.viewMode = ViewModeMessageList + m.textarea.Reset() + m.textarea.Blur() + return m, nil + + case tea.KeyCtrlS: + // Save the edit + m.debugLog("Ctrl+S pressed, saving edit") + return m, m.saveEditedMessage() } } @@ -332,6 +431,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err m.addMessage(fmt.Sprintf("Error: %v", msg.err), "error") m.updateViewport() + + case messagesLoadedMsg: + m.debugLog("messagesLoadedMsg: loaded %d messages", len(msg.messages)) + m.llmMessages = msg.messages + m.selectedMessageIdx = 0 + if len(m.llmMessages) > 0 { + m.selectedMessageIdx = len(m.llmMessages) - 1 // Start at most recent + } + m.viewMode = ViewModeMessageList + m.textarea.Blur() + m.updateMessageListViewport() + + case messageEditedMsg: + m.debugLog("messageEditedMsg: message edited successfully") + m.viewMode = ViewModeChat + m.textarea.Reset() + m.textarea.Focus() + m.addMessage("Message edited successfully. The thread has been updated.", "system") + m.updateViewport() + // Reload messages to get updated list + return m, m.loadMessages() } return m, tea.Batch(tiCmd, vpCmd) @@ -343,7 +463,20 @@ func (m Model) View() string { return "Loading..." } - // Build the view + switch m.viewMode { + case ViewModeChat: + return m.viewChat() + case ViewModeMessageList: + return m.viewMessageList() + case ViewModeEditing: + return m.viewEditing() + default: + return "Unknown view mode" + } +} + +// viewChat renders the chat view +func (m Model) viewChat() string { var b strings.Builder // Header @@ -366,7 +499,7 @@ func (m Model) View() string { b.WriteString("\n") // Help text - help := helpStyle.Render("Enter: send • Esc: quit") + help := helpStyle.Render("Enter: send • Ctrl+L: list messages • Esc: quit") if m.waiting { help = helpStyle.Render("Waiting for response...") } @@ -375,6 +508,55 @@ func (m Model) View() string { return b.String() } +// viewMessageList renders the message list view +func (m Model) viewMessageList() string { + var b strings.Builder + + // Header + title := titleStyle.Render("Message List - Select a message to edit") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n") + + // Message list + b.WriteString(m.messageListViewport.View()) + b.WriteString("\n") + + // Help text + help := helpStyle.Render("↑/↓: navigate • Enter: edit • Esc: back to chat") + b.WriteString(help) + + return b.String() +} + +// viewEditing renders the editing view +func (m Model) viewEditing() string { + var b strings.Builder + + // Header + title := titleStyle.Render("Editing Message") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n\n") + + // Editing instructions + instructions := helpStyle.Render("Edit the message content below:") + b.WriteString(instructions) + b.WriteString("\n\n") + + // Textarea for editing + b.WriteString(inputStyle.Render(m.textarea.View())) + b.WriteString("\n\n") + + // Help text + help := helpStyle.Render("Ctrl+S: save • Esc: cancel") + b.WriteString(help) + + return b.String() +} + // sendMessage sends the current message to the API func (m *Model) sendMessage() tea.Cmd { text := strings.TrimSpace(m.textarea.Value()) @@ -576,3 +758,150 @@ func formatMarkdown(text string) string { }) return result } + +// loadMessages loads all messages from the current thread +func (m *Model) loadMessages() tea.Cmd { + return func() tea.Msg { + m.debugLog("loadMessages: loading messages for thread %s", m.threadID) + threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) + + // Fetch all messages (use large page size) + resp, err := m.client.ListLlmMessages(m.ctx, threadPath, 1000, "") + if err != nil { + m.debugLog("loadMessages: failed to load messages: %v", err) + return errMsg{err: fmt.Errorf("failed to load messages: %w", err)} + } + + m.debugLog("loadMessages: loaded %d messages", len(resp.Results)) + return messagesLoadedMsg{messages: resp.Results} + } +} + +// updateMessageListViewport updates the message list viewport with current messages +func (m *Model) updateMessageListViewport() { + if len(m.llmMessages) == 0 { + m.messageListViewport.SetContent("No messages found.") + return + } + + var b strings.Builder + for i, msg := range m.llmMessages { + // Format message for display + role := "Unknown" + switch msg.Role { + case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER: + role = "User" + case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT: + role = "Assistant" + } + + // Get message content preview (first text content) + contentPreview := "" + if len(msg.Contents) > 0 { + for _, content := range msg.Contents { + if text := content.GetText(); text != "" { + // Truncate long text + if len(text) > 100 { + contentPreview = text[:100] + "..." + } else { + contentPreview = text + } + break + } + } + } + if contentPreview == "" { + contentPreview = "[No text content]" + } + + // Format the line + line := fmt.Sprintf("%d. [%s] %s", msg.Index, role, contentPreview) + + // Apply style based on selection + if i == m.selectedMessageIdx { + line = selectedItemStyle.Render("> " + line) + } else { + line = messageListItemStyle.Render(" " + line) + } + + b.WriteString(line) + b.WriteString("\n") + } + + m.messageListViewport.SetContent(b.String()) +} + +// startEditingMessage starts editing a message +func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { + m.debugLog("startEditingMessage: editing message %s", msg.Path) + + // Only allow editing user messages + if msg.Role != threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER { + m.debugLog("startEditingMessage: can only edit user messages, role=%v", msg.Role) + return func() tea.Msg { + return errMsg{err: fmt.Errorf("can only edit user messages")} + } + } + + // Get the text content from the message + textContent := "" + if len(msg.Contents) > 0 { + for _, content := range msg.Contents { + if text := content.GetText(); text != "" { + textContent = text + break + } + } + } + + if textContent == "" { + m.debugLog("startEditingMessage: message has no text content") + return func() tea.Msg { + return errMsg{err: fmt.Errorf("message has no text content to edit")} + } + } + + // Set up editing state + m.editingMessagePath = msg.Path + m.editingMessageOrig = textContent + m.textarea.SetValue(textContent) + m.textarea.Focus() + m.viewMode = ViewModeEditing + + return nil +} + +// saveEditedMessage saves the edited message +func (m *Model) saveEditedMessage() tea.Cmd { + newContent := strings.TrimSpace(m.textarea.Value()) + if newContent == "" { + m.debugLog("saveEditedMessage: empty content, not saving") + return func() tea.Msg { + return errMsg{err: fmt.Errorf("message content cannot be empty")} + } + } + + if newContent == m.editingMessageOrig { + m.debugLog("saveEditedMessage: no changes made") + // No changes, just return to message list + m.viewMode = ViewModeMessageList + m.textarea.Reset() + m.textarea.Blur() + return nil + } + + messagePath := m.editingMessagePath + return func() tea.Msg { + m.debugLog("saveEditedMessage: saving edited message to %s", messagePath) + + // Call the API to edit the message + _, err := m.client.EditThreadMessage(m.ctx, messagePath, newContent, false) + if err != nil { + m.debugLog("saveEditedMessage: failed to edit message: %v", err) + return errMsg{err: fmt.Errorf("failed to edit message: %w", err)} + } + + m.debugLog("saveEditedMessage: message edited successfully") + return messageEditedMsg{} + } +} diff --git a/tim-db/gen/db/checkpoint.sql.go b/tim-db/gen/db/checkpoint.sql.go new file mode 100644 index 000000000..2481bc5fa --- /dev/null +++ b/tim-db/gen/db/checkpoint.sql.go @@ -0,0 +1,555 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: checkpoint.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const countFileSnapshotsByCheckpoint = `-- name: CountFileSnapshotsByCheckpoint :one +SELECT COUNT(*) +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = $1::uuid +` + +func (q *Queries) CountFileSnapshotsByCheckpoint(ctx context.Context, checkpointUid uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countFileSnapshotsByCheckpoint, checkpointUid) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createCheckpoint = `-- name: CreateCheckpoint :one + +INSERT INTO thread_checkpoint ( + thread_uid, + message_uid, + working_directory, + total_files, + total_size_bytes +) VALUES ( + $1::uuid, + $2::uuid, + $3::text, + $4::int, + $5::bigint +) RETURNING uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time +` + +type CreateCheckpointParams struct { + ThreadUID uuid.UUID + MessageUID uuid.UUID + WorkingDirectory string + TotalFiles int32 + TotalSizeBytes int64 +} + +// Checkpoint Queries +func (q *Queries) CreateCheckpoint(ctx context.Context, arg CreateCheckpointParams) (ThreadCheckpoint, error) { + row := q.db.QueryRow(ctx, createCheckpoint, + arg.ThreadUID, + arg.MessageUID, + arg.WorkingDirectory, + arg.TotalFiles, + arg.TotalSizeBytes, + ) + var i ThreadCheckpoint + err := row.Scan( + &i.UID, + &i.ThreadUID, + &i.MessageUID, + &i.WorkingDirectory, + &i.TotalFiles, + &i.TotalSizeBytes, + &i.CreateTime, + ) + return i, err +} + +const createFileSnapshot = `-- name: CreateFileSnapshot :one + +INSERT INTO checkpoint_file_snapshot ( + checkpoint_uid, + file_path, + is_base_snapshot, + content, + file_hash, + file_size, + file_mode, + modified_time, + is_deleted +) VALUES ( + $1::uuid, + $2::text, + $3::boolean, + $4::jsonb, + $5::text, + $6::bigint, + $7::int, + $8::timestamptz, + $9::boolean +) RETURNING uid, checkpoint_uid, file_path, is_base_snapshot, content, file_hash, file_size, file_mode, modified_time, is_deleted, create_time +` + +type CreateFileSnapshotParams struct { + CheckpointUid uuid.UUID + FilePath string + IsBaseSnapshot bool + Content []byte + FileHash pgtype.Text + FileSize pgtype.Int8 + FileMode pgtype.Int4 + ModifiedTime pgtype.Timestamptz + IsDeleted bool +} + +// File Snapshot Queries +func (q *Queries) CreateFileSnapshot(ctx context.Context, arg CreateFileSnapshotParams) (CheckpointFileSnapshot, error) { + row := q.db.QueryRow(ctx, createFileSnapshot, + arg.CheckpointUid, + arg.FilePath, + arg.IsBaseSnapshot, + arg.Content, + arg.FileHash, + arg.FileSize, + arg.FileMode, + arg.ModifiedTime, + arg.IsDeleted, + ) + var i CheckpointFileSnapshot + err := row.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ) + return i, err +} + +const deleteCheckpoint = `-- name: DeleteCheckpoint :exec +DELETE FROM thread_checkpoint +WHERE uid = $1::uuid +` + +func (q *Queries) DeleteCheckpoint(ctx context.Context, checkpointUid uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteCheckpoint, checkpointUid) + return err +} + +const deleteCheckpointsByThread = `-- name: DeleteCheckpointsByThread :exec +DELETE FROM thread_checkpoint +WHERE thread_uid = $1::uuid +` + +func (q *Queries) DeleteCheckpointsByThread(ctx context.Context, threadUid uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteCheckpointsByThread, threadUid) + return err +} + +const getBaseSnapshotForFile = `-- name: GetBaseSnapshotForFile :one +SELECT cfs.uid, cfs.checkpoint_uid, cfs.file_path, cfs.is_base_snapshot, cfs.content, cfs.file_hash, cfs.file_size, cfs.file_mode, cfs.modified_time, cfs.is_deleted, cfs.create_time +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = $1::uuid + AND cfs.file_path = $2::text + AND cfs.is_base_snapshot = true + AND tc.create_time <= ( + SELECT create_time + FROM thread_checkpoint + WHERE uid = $3::uuid + ) +ORDER BY tc.create_time DESC +LIMIT 1 +` + +type GetBaseSnapshotForFileParams struct { + ThreadUID uuid.UUID + FilePath string + CheckpointUid uuid.UUID +} + +// Get the most recent base snapshot for a file before or at the given checkpoint +// Used for reconstructing file content from diffs +func (q *Queries) GetBaseSnapshotForFile(ctx context.Context, arg GetBaseSnapshotForFileParams) (CheckpointFileSnapshot, error) { + row := q.db.QueryRow(ctx, getBaseSnapshotForFile, arg.ThreadUID, arg.FilePath, arg.CheckpointUid) + var i CheckpointFileSnapshot + err := row.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ) + return i, err +} + +const getCheckpoint = `-- name: GetCheckpoint :one +SELECT uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time +FROM thread_checkpoint +WHERE uid = $1::uuid +` + +func (q *Queries) GetCheckpoint(ctx context.Context, checkpointUid uuid.UUID) (ThreadCheckpoint, error) { + row := q.db.QueryRow(ctx, getCheckpoint, checkpointUid) + var i ThreadCheckpoint + err := row.Scan( + &i.UID, + &i.ThreadUID, + &i.MessageUID, + &i.WorkingDirectory, + &i.TotalFiles, + &i.TotalSizeBytes, + &i.CreateTime, + ) + return i, err +} + +const getCheckpointByMessage = `-- name: GetCheckpointByMessage :one +SELECT uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time +FROM thread_checkpoint +WHERE message_uid = $1::uuid +` + +func (q *Queries) GetCheckpointByMessage(ctx context.Context, messageUid uuid.UUID) (ThreadCheckpoint, error) { + row := q.db.QueryRow(ctx, getCheckpointByMessage, messageUid) + var i ThreadCheckpoint + err := row.Scan( + &i.UID, + &i.ThreadUID, + &i.MessageUID, + &i.WorkingDirectory, + &i.TotalFiles, + &i.TotalSizeBytes, + &i.CreateTime, + ) + return i, err +} + +const getCheckpointByThreadAndMessage = `-- name: GetCheckpointByThreadAndMessage :one +SELECT uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time +FROM thread_checkpoint +WHERE thread_uid = $1::uuid + AND message_uid = $2::uuid +` + +type GetCheckpointByThreadAndMessageParams struct { + ThreadUID uuid.UUID + MessageUID uuid.UUID +} + +func (q *Queries) GetCheckpointByThreadAndMessage(ctx context.Context, arg GetCheckpointByThreadAndMessageParams) (ThreadCheckpoint, error) { + row := q.db.QueryRow(ctx, getCheckpointByThreadAndMessage, arg.ThreadUID, arg.MessageUID) + var i ThreadCheckpoint + err := row.Scan( + &i.UID, + &i.ThreadUID, + &i.MessageUID, + &i.WorkingDirectory, + &i.TotalFiles, + &i.TotalSizeBytes, + &i.CreateTime, + ) + return i, err +} + +const getCheckpointStats = `-- name: GetCheckpointStats :one +SELECT + COUNT(*) as file_count, + COALESCE(SUM(file_size), 0) as total_size +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = $1::uuid + AND is_deleted = false +` + +type GetCheckpointStatsRow struct { + FileCount int64 + TotalSize interface{} +} + +// Get checkpoint statistics (total files, total size) +func (q *Queries) GetCheckpointStats(ctx context.Context, checkpointUid uuid.UUID) (GetCheckpointStatsRow, error) { + row := q.db.QueryRow(ctx, getCheckpointStats, checkpointUid) + var i GetCheckpointStatsRow + err := row.Scan(&i.FileCount, &i.TotalSize) + return i, err +} + +const getFileSnapshot = `-- name: GetFileSnapshot :one +SELECT uid, checkpoint_uid, file_path, is_base_snapshot, content, file_hash, file_size, file_mode, modified_time, is_deleted, create_time +FROM checkpoint_file_snapshot +WHERE uid = $1::uuid +` + +func (q *Queries) GetFileSnapshot(ctx context.Context, snapshotUid uuid.UUID) (CheckpointFileSnapshot, error) { + row := q.db.QueryRow(ctx, getFileSnapshot, snapshotUid) + var i CheckpointFileSnapshot + err := row.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ) + return i, err +} + +const getFileSnapshotByPath = `-- name: GetFileSnapshotByPath :one +SELECT uid, checkpoint_uid, file_path, is_base_snapshot, content, file_hash, file_size, file_mode, modified_time, is_deleted, create_time +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = $1::uuid + AND file_path = $2::text +` + +type GetFileSnapshotByPathParams struct { + CheckpointUid uuid.UUID + FilePath string +} + +func (q *Queries) GetFileSnapshotByPath(ctx context.Context, arg GetFileSnapshotByPathParams) (CheckpointFileSnapshot, error) { + row := q.db.QueryRow(ctx, getFileSnapshotByPath, arg.CheckpointUid, arg.FilePath) + var i CheckpointFileSnapshot + err := row.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ) + return i, err +} + +const getLastFileSnapshot = `-- name: GetLastFileSnapshot :one +SELECT cfs.uid, cfs.checkpoint_uid, cfs.file_path, cfs.is_base_snapshot, cfs.content, cfs.file_hash, cfs.file_size, cfs.file_mode, cfs.modified_time, cfs.is_deleted, cfs.create_time +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = $1::uuid + AND cfs.file_path = $2::text +ORDER BY tc.create_time DESC +LIMIT 1 +` + +type GetLastFileSnapshotParams struct { + ThreadUID uuid.UUID + FilePath string +} + +// Get the most recent snapshot (base or diff) for a file in a thread +// Used to determine if file has changed since last checkpoint +func (q *Queries) GetLastFileSnapshot(ctx context.Context, arg GetLastFileSnapshotParams) (CheckpointFileSnapshot, error) { + row := q.db.QueryRow(ctx, getLastFileSnapshot, arg.ThreadUID, arg.FilePath) + var i CheckpointFileSnapshot + err := row.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ) + return i, err +} + +const getThreadWorkingDirectory = `-- name: GetThreadWorkingDirectory :one +SELECT working_directory +FROM thread +WHERE uid = $1::uuid +` + +func (q *Queries) GetThreadWorkingDirectory(ctx context.Context, threadUid uuid.UUID) (pgtype.Text, error) { + row := q.db.QueryRow(ctx, getThreadWorkingDirectory, threadUid) + var working_directory pgtype.Text + err := row.Scan(&working_directory) + return working_directory, err +} + +const listCheckpointsByThread = `-- name: ListCheckpointsByThread :many +SELECT uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time +FROM thread_checkpoint +WHERE thread_uid = $1::uuid +ORDER BY create_time DESC +LIMIT $2::int +` + +type ListCheckpointsByThreadParams struct { + ThreadUID uuid.UUID + PageLimit int32 +} + +func (q *Queries) ListCheckpointsByThread(ctx context.Context, arg ListCheckpointsByThreadParams) ([]ThreadCheckpoint, error) { + rows, err := q.db.Query(ctx, listCheckpointsByThread, arg.ThreadUID, arg.PageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ThreadCheckpoint + for rows.Next() { + var i ThreadCheckpoint + if err := rows.Scan( + &i.UID, + &i.ThreadUID, + &i.MessageUID, + &i.WorkingDirectory, + &i.TotalFiles, + &i.TotalSizeBytes, + &i.CreateTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDiffSnapshotsForFile = `-- name: ListDiffSnapshotsForFile :many +SELECT cfs.uid, cfs.checkpoint_uid, cfs.file_path, cfs.is_base_snapshot, cfs.content, cfs.file_hash, cfs.file_size, cfs.file_mode, cfs.modified_time, cfs.is_deleted, cfs.create_time +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = $1::uuid + AND cfs.file_path = $2::text + AND cfs.is_base_snapshot = false + AND tc.create_time > $3::timestamptz + AND tc.create_time <= $4::timestamptz +ORDER BY tc.create_time ASC +` + +type ListDiffSnapshotsForFileParams struct { + ThreadUID uuid.UUID + FilePath string + BaseCheckpointTime pgtype.Timestamptz + TargetCheckpointTime pgtype.Timestamptz +} + +// Get all diff snapshots for a file between base and target checkpoint +// Used for reconstructing file content from diffs +func (q *Queries) ListDiffSnapshotsForFile(ctx context.Context, arg ListDiffSnapshotsForFileParams) ([]CheckpointFileSnapshot, error) { + rows, err := q.db.Query(ctx, listDiffSnapshotsForFile, + arg.ThreadUID, + arg.FilePath, + arg.BaseCheckpointTime, + arg.TargetCheckpointTime, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CheckpointFileSnapshot + for rows.Next() { + var i CheckpointFileSnapshot + if err := rows.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFileSnapshots = `-- name: ListFileSnapshots :many +SELECT uid, checkpoint_uid, file_path, is_base_snapshot, content, file_hash, file_size, file_mode, modified_time, is_deleted, create_time +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = $1::uuid +ORDER BY file_path ASC +` + +func (q *Queries) ListFileSnapshots(ctx context.Context, checkpointUid uuid.UUID) ([]CheckpointFileSnapshot, error) { + rows, err := q.db.Query(ctx, listFileSnapshots, checkpointUid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CheckpointFileSnapshot + for rows.Next() { + var i CheckpointFileSnapshot + if err := rows.Scan( + &i.UID, + &i.CheckpointUid, + &i.FilePath, + &i.IsBaseSnapshot, + &i.Content, + &i.FileHash, + &i.FileSize, + &i.FileMode, + &i.ModifiedTime, + &i.IsDeleted, + &i.CreateTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateThreadWorkingDirectory = `-- name: UpdateThreadWorkingDirectory :exec + +UPDATE thread +SET working_directory = $1::text +WHERE uid = $2::uuid +` + +type UpdateThreadWorkingDirectoryParams struct { + WorkingDirectory string + ThreadUID uuid.UUID +} + +// Thread Working Directory Queries +func (q *Queries) UpdateThreadWorkingDirectory(ctx context.Context, arg UpdateThreadWorkingDirectoryParams) error { + _, err := q.db.Exec(ctx, updateThreadWorkingDirectory, arg.WorkingDirectory, arg.ThreadUID) + return err +} diff --git a/tim-db/gen/db/models.go b/tim-db/gen/db/models.go index e2b8c531f..bad762785 100644 --- a/tim-db/gen/db/models.go +++ b/tim-db/gen/db/models.go @@ -488,6 +488,20 @@ func (ns NullToolChoice) Value() (driver.Value, error) { return string(ns.ToolChoice), nil } +type CheckpointFileSnapshot struct { + UID uuid.UUID + CheckpointUid uuid.UUID + FilePath string + IsBaseSnapshot bool + Content []byte + FileHash pgtype.Text + FileSize pgtype.Int8 + FileMode pgtype.Int4 + ModifiedTime pgtype.Timestamptz + IsDeleted bool + CreateTime pgtype.Timestamptz +} + type CreditBalance struct { OrganizationUID uuid.UUID CreditsRemaining int64 @@ -539,6 +553,8 @@ type LlmMessage struct { CacheReadInputTokens pgtype.Int4 CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz + // Timestamp when the message was soft deleted. NULL means the message is active. + DeletedAt pgtype.Timestamptz } type LlmMessageContent struct { @@ -662,6 +678,17 @@ type Thread struct { LlmStatus ThreadLlmStatus CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text +} + +type ThreadCheckpoint struct { + UID uuid.UUID + ThreadUID uuid.UUID + MessageUID uuid.UUID + WorkingDirectory string + TotalFiles int32 + TotalSizeBytes int64 + CreateTime pgtype.Timestamptz } type ThreadContext struct { diff --git a/tim-db/gen/db/thread.sql.go b/tim-db/gen/db/thread.sql.go index 81fb47c58..a318f26d1 100644 --- a/tim-db/gen/db/thread.sql.go +++ b/tim-db/gen/db/thread.sql.go @@ -81,7 +81,7 @@ INSERT INTO llm_message ( $8::timestamptz, $9::timestamptz ) -RETURNING uid, origin_thread_uid, idx, role, model_id, use_thinking, use_interleaved_thinking, stream_status, stream_started_at, stream_completed_at, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, create_time, update_time +RETURNING uid, origin_thread_uid, idx, role, model_id, use_thinking, use_interleaved_thinking, stream_status, stream_started_at, stream_completed_at, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, create_time, update_time, deleted_at ` type CreateMessageParams struct { @@ -126,6 +126,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (L &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ) return i, err } @@ -208,7 +209,7 @@ INSERT INTO thread ( $5::uuid, $6 ) -RETURNING uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time +RETURNING uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time, working_directory ` type CreateThreadParams struct { @@ -241,16 +242,36 @@ func (q *Queries) CreateThread(ctx context.Context, arg CreateThreadParams) (Thr &i.LlmStatus, &i.CreateTime, &i.UpdateTime, + &i.WorkingDirectory, ) return i, err } +const deleteMessagesAfterIndex = `-- name: DeleteMessagesAfterIndex :exec +UPDATE llm_message +SET deleted_at = NOW() +WHERE origin_thread_uid = $1::uuid + AND idx > $2::int + AND deleted_at IS NULL +` + +type DeleteMessagesAfterIndexParams struct { + ThreadUID uuid.UUID + Idx int32 +} + +func (q *Queries) DeleteMessagesAfterIndex(ctx context.Context, arg DeleteMessagesAfterIndexParams) error { + _, err := q.db.Exec(ctx, deleteMessagesAfterIndex, arg.ThreadUID, arg.Idx) + return err +} + const getLastThreadMessage = `-- name: GetLastThreadMessage :one SELECT - llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time + llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time, llm_message.deleted_at FROM llm_message INNER JOIN thread_message ON llm_message.uid = thread_message.message_uid WHERE thread_message.thread_uid = $1::uuid + AND llm_message.deleted_at IS NULL ORDER BY llm_message.idx DESC, llm_message.uid DESC LIMIT 1 ` @@ -275,14 +296,16 @@ func (q *Queries) GetLastThreadMessage(ctx context.Context, threadUid uuid.UUID) &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ) return i, err } const getMessageByUID = `-- name: GetMessageByUID :one -SELECT uid, origin_thread_uid, idx, role, model_id, use_thinking, use_interleaved_thinking, stream_status, stream_started_at, stream_completed_at, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, create_time, update_time +SELECT uid, origin_thread_uid, idx, role, model_id, use_thinking, use_interleaved_thinking, stream_status, stream_started_at, stream_completed_at, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, create_time, update_time, deleted_at FROM llm_message WHERE uid = $1::uuid + AND deleted_at IS NULL ` func (q *Queries) GetMessageByUID(ctx context.Context, messageUid uuid.UUID) (LlmMessage, error) { @@ -305,13 +328,14 @@ func (q *Queries) GetMessageByUID(ctx context.Context, messageUid uuid.UUID) (Ll &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ) return i, err } const getThread = `-- name: GetThread :one -SELECT - t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, +SELECT + t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, t.working_directory, pr.persona_uid as persona_uid FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid @@ -337,6 +361,7 @@ type GetThreadRow struct { LlmStatus ThreadLlmStatus CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text PersonaUID uuid.UUID } @@ -354,13 +379,14 @@ func (q *Queries) GetThread(ctx context.Context, arg GetThreadParams) (GetThread &i.LlmStatus, &i.CreateTime, &i.UpdateTime, + &i.WorkingDirectory, &i.PersonaUID, ) return i, err } const getThreadByUID = `-- name: GetThreadByUID :one -SELECT uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time +SELECT uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time, working_directory FROM thread WHERE uid = $1::uuid ` @@ -379,16 +405,18 @@ func (q *Queries) GetThreadByUID(ctx context.Context, threadUid uuid.UUID) (Thre &i.LlmStatus, &i.CreateTime, &i.UpdateTime, + &i.WorkingDirectory, ) return i, err } const getThreadMessage = `-- name: GetThreadMessage :one SELECT - llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time + llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time, llm_message.deleted_at FROM llm_message WHERE uid = $1::uuid AND origin_thread_uid = $2::uuid + AND deleted_at IS NULL ` type GetThreadMessageParams struct { @@ -416,13 +444,14 @@ func (q *Queries) GetThreadMessage(ctx context.Context, arg GetThreadMessagePara &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ) return i, err } const getThreadWithPersonaRevision = `-- name: GetThreadWithPersonaRevision :one SELECT - t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, + t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, t.working_directory, pr.model_id as persona_model_id, pr.tools as persona_tools, pr.tool_choice as persona_tool_choice, @@ -455,6 +484,7 @@ type GetThreadWithPersonaRevisionRow struct { LlmStatus ThreadLlmStatus CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text PersonaModelID string PersonaTools []string PersonaToolChoice NullToolChoice @@ -479,6 +509,7 @@ func (q *Queries) GetThreadWithPersonaRevision(ctx context.Context, arg GetThrea &i.LlmStatus, &i.CreateTime, &i.UpdateTime, + &i.WorkingDirectory, &i.PersonaModelID, &i.PersonaTools, &i.PersonaToolChoice, @@ -532,10 +563,11 @@ func (q *Queries) ListMessageContent(ctx context.Context, messageUid uuid.UUID) const listThreadMessages = `-- name: ListThreadMessages :many SELECT - llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time + llm_message.uid, llm_message.origin_thread_uid, llm_message.idx, llm_message.role, llm_message.model_id, llm_message.use_thinking, llm_message.use_interleaved_thinking, llm_message.stream_status, llm_message.stream_started_at, llm_message.stream_completed_at, llm_message.input_tokens, llm_message.output_tokens, llm_message.cache_creation_input_tokens, llm_message.cache_read_input_tokens, llm_message.create_time, llm_message.update_time, llm_message.deleted_at FROM llm_message INNER JOIN thread_message ON llm_message.uid = thread_message.message_uid WHERE thread_message.thread_uid = $1::uuid + AND llm_message.deleted_at IS NULL -- Cursor-based pagination AND ($2::int IS NULL OR (llm_message.idx > $2::int OR @@ -582,6 +614,7 @@ func (q *Queries) ListThreadMessages(ctx context.Context, arg ListThreadMessages &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ); err != nil { return nil, err } @@ -594,8 +627,8 @@ func (q *Queries) ListThreadMessages(ctx context.Context, arg ListThreadMessages } const listThreads = `-- name: ListThreads :many -SELECT - t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, +SELECT + t.uid, t.parent_uid, t.owner_uid, t.organization_uid, t.persona_revision_uid, t.active_context_uid, t.title, t.llm_status, t.create_time, t.update_time, t.working_directory, pr.persona_uid as persona_uid FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid @@ -628,6 +661,7 @@ type ListThreadsRow struct { LlmStatus ThreadLlmStatus CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text PersonaUID uuid.UUID } @@ -657,6 +691,7 @@ func (q *Queries) ListThreads(ctx context.Context, arg ListThreadsParams) ([]Lis &i.LlmStatus, &i.CreateTime, &i.UpdateTime, + &i.WorkingDirectory, &i.PersonaUID, ); err != nil { return nil, err diff --git a/tim-db/gen/db/tool_execution.sql.go b/tim-db/gen/db/tool_execution.sql.go index 5317afd86..6c8314e1f 100644 --- a/tim-db/gen/db/tool_execution.sql.go +++ b/tim-db/gen/db/tool_execution.sql.go @@ -14,7 +14,7 @@ import ( const checkAnyToolResultHasStopIteration = `-- name: CheckAnyToolResultHasStopIteration :one SELECT EXISTS( - SELECT 1 + SELECT 1 FROM llm_message_content WHERE message_uid = $1::uuid AND type = 'tool_result' @@ -33,13 +33,14 @@ func (q *Queries) CheckAnyToolResultHasStopIteration(ctx context.Context, messag const checkToolResultExists = `-- name: CheckToolResultExists :one SELECT EXISTS( - SELECT 1 + SELECT 1 FROM llm_message_content result_mc JOIN llm_message result_m ON result_mc.message_uid = result_m.uid JOIN thread_message result_tm ON result_m.uid = result_tm.message_uid - WHERE + WHERE result_tm.thread_uid = $1::uuid AND result_m.role = 'user' + AND result_m.deleted_at IS NULL AND result_mc.type = 'tool_result' AND result_mc.stream_status = 'complete' AND result_mc.content->>'tool_use_id' = $2::text @@ -113,7 +114,7 @@ func (q *Queries) GetNextMessageContentIdx(ctx context.Context, messageUid uuid. } const getToolCallByID = `-- name: GetToolCallByID :one -SELECT +SELECT mc.uid as content_uid, mc.content, mc.create_time, @@ -125,11 +126,12 @@ FROM llm_message_content mc JOIN llm_message m ON mc.message_uid = m.uid JOIN thread_message tm ON m.uid = tm.message_uid JOIN thread t ON tm.thread_uid = t.uid -WHERE +WHERE tm.thread_uid = $1::uuid AND t.organization_uid = $2::uuid AND t.owner_uid = $3::uuid AND m.role = 'assistant' + AND m.deleted_at IS NULL AND mc.type = 'tool_use' AND mc.stream_status = 'complete' AND mc.content->>'id' = $4::text @@ -176,7 +178,7 @@ func (q *Queries) GetToolCallByID(ctx context.Context, arg GetToolCallByIDParams } const getToolCallByIDInternal = `-- name: GetToolCallByIDInternal :one -SELECT +SELECT mc.uid as content_uid, mc.content, mc.create_time, @@ -185,9 +187,10 @@ SELECT FROM llm_message_content mc JOIN llm_message m ON mc.message_uid = m.uid JOIN thread_message tm ON m.uid = tm.message_uid -WHERE +WHERE tm.thread_uid = $1::uuid AND m.role = 'assistant' + AND m.deleted_at IS NULL AND mc.type = 'tool_use' AND mc.stream_status = 'complete' AND mc.content->>'id' = $2::text @@ -222,11 +225,12 @@ func (q *Queries) GetToolCallByIDInternal(ctx context.Context, arg GetToolCallBy } const getUserMessageAfter = `-- name: GetUserMessageAfter :one -SELECT m.uid, m.origin_thread_uid, m.idx, m.role, m.model_id, m.use_thinking, m.use_interleaved_thinking, m.stream_status, m.stream_started_at, m.stream_completed_at, m.input_tokens, m.output_tokens, m.cache_creation_input_tokens, m.cache_read_input_tokens, m.create_time, m.update_time +SELECT m.uid, m.origin_thread_uid, m.idx, m.role, m.model_id, m.use_thinking, m.use_interleaved_thinking, m.stream_status, m.stream_started_at, m.stream_completed_at, m.input_tokens, m.output_tokens, m.cache_creation_input_tokens, m.cache_read_input_tokens, m.create_time, m.update_time, m.deleted_at FROM llm_message m JOIN thread_message tm ON m.uid = tm.message_uid WHERE tm.thread_uid = $1::uuid AND m.role = 'user' + AND m.deleted_at IS NULL AND m.idx > $2 AND m.origin_thread_uid = $1::uuid ORDER BY m.idx ASC @@ -261,6 +265,7 @@ func (q *Queries) GetUserMessageAfter(ctx context.Context, arg GetUserMessageAft &i.CacheReadInputTokens, &i.CreateTime, &i.UpdateTime, + &i.DeletedAt, ) return i, err } diff --git a/tim-db/migrations/20251105000000_create_thread_checkpoints.sql b/tim-db/migrations/20251105000000_create_thread_checkpoints.sql new file mode 100644 index 000000000..c020e15f8 --- /dev/null +++ b/tim-db/migrations/20251105000000_create_thread_checkpoints.sql @@ -0,0 +1,69 @@ +-- migrate:up + +-- Table for storing checkpoint metadata +-- Each checkpoint captures the state of the working directory after a message +CREATE TABLE thread_checkpoint ( + uid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_uid UUID NOT NULL, + message_uid UUID NOT NULL, + working_directory TEXT NOT NULL, + total_files INT NOT NULL DEFAULT 0, + total_size_bytes BIGINT NOT NULL DEFAULT 0, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_checkpoint_thread + FOREIGN KEY (thread_uid) REFERENCES thread(uid) + ON DELETE CASCADE, + CONSTRAINT fk_checkpoint_message + FOREIGN KEY (message_uid) REFERENCES llm_message(uid) + ON DELETE CASCADE +); + +-- Table for storing file snapshots within checkpoints +-- Uses diff-based storage: first snapshot of a file stores full content, +-- subsequent snapshots store compact line-based diffs in JSONB format +CREATE TABLE checkpoint_file_snapshot ( + uid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + checkpoint_uid UUID NOT NULL, + file_path TEXT NOT NULL, + is_base_snapshot BOOLEAN NOT NULL DEFAULT false, + -- For base snapshots: stores full file content as lines array + -- For diff snapshots: stores compact diff as {"lines": {"5": "new", "10": "changed"}, "deleted": [7,8]} + -- For deleted files: NULL + content JSONB, + file_hash TEXT, -- blake2b hash for detecting changes + file_size BIGINT, + file_mode INT, -- Unix file permissions + modified_time TIMESTAMPTZ, + is_deleted BOOLEAN NOT NULL DEFAULT false, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_snapshot_checkpoint + FOREIGN KEY (checkpoint_uid) REFERENCES thread_checkpoint(uid) + ON DELETE CASCADE +); + +-- Index for fast checkpoint lookup by thread and message +CREATE INDEX idx_thread_checkpoint_thread_message +ON thread_checkpoint (thread_uid, message_uid); + +-- Index for fast file snapshot retrieval +CREATE INDEX idx_checkpoint_file_snapshot_checkpoint +ON checkpoint_file_snapshot (checkpoint_uid); + +-- Index for file path lookup within checkpoint (for restoration) +CREATE INDEX idx_checkpoint_file_snapshot_path +ON checkpoint_file_snapshot (checkpoint_uid, file_path); + +-- Index to optimize message deletion queries (used in checkpoint restoration) +CREATE INDEX idx_llm_message_thread_idx +ON llm_message (origin_thread_uid, idx); + +-- migrate:down + +DROP INDEX IF EXISTS idx_llm_message_thread_idx; +DROP INDEX IF EXISTS idx_checkpoint_file_snapshot_path; +DROP INDEX IF EXISTS idx_checkpoint_file_snapshot_checkpoint; +DROP INDEX IF EXISTS idx_thread_checkpoint_thread_message; + +DROP TABLE IF EXISTS checkpoint_file_snapshot; +DROP TABLE IF EXISTS thread_checkpoint; + diff --git a/tim-db/migrations/20251105000001_add_thread_working_directory.sql b/tim-db/migrations/20251105000001_add_thread_working_directory.sql new file mode 100644 index 000000000..9b303de2c --- /dev/null +++ b/tim-db/migrations/20251105000001_add_thread_working_directory.sql @@ -0,0 +1,22 @@ +-- migrate:up + +-- Add working_directory column to thread table +-- This is nullable because: +-- 1. Web-only threads don't have a working directory +-- 2. Existing threads don't have this value yet +-- 3. CLI determines this dynamically when connecting +ALTER TABLE thread +ADD COLUMN working_directory TEXT DEFAULT NULL; + +-- Index for threads with working directories (for checkpoint queries) +CREATE INDEX idx_thread_working_directory +ON thread (working_directory) +WHERE working_directory IS NOT NULL; + +-- migrate:down + +DROP INDEX IF EXISTS idx_thread_working_directory; + +ALTER TABLE thread +DROP COLUMN IF EXISTS working_directory; + diff --git a/tim-db/migrations/20251105204430_add_deleted_at_to_llm_message.sql b/tim-db/migrations/20251105204430_add_deleted_at_to_llm_message.sql new file mode 100644 index 000000000..474c648af --- /dev/null +++ b/tim-db/migrations/20251105204430_add_deleted_at_to_llm_message.sql @@ -0,0 +1,24 @@ +-- migrate:up + +-- Add deleted_at column to llm_message table for soft delete support +ALTER TABLE llm_message +ADD COLUMN deleted_at timestamptz DEFAULT NULL; + +-- Add index for efficient querying of non-deleted messages +-- Most queries will filter WHERE deleted_at IS NULL +CREATE INDEX idx_llm_message_deleted_at +ON llm_message (deleted_at) +WHERE deleted_at IS NULL; + +-- Add comment explaining the soft delete column +COMMENT ON COLUMN llm_message.deleted_at IS 'Timestamp when the message was soft deleted. NULL means the message is active.'; + +-- migrate:down + +-- Remove the index +DROP INDEX IF EXISTS idx_llm_message_deleted_at; + +-- Remove the deleted_at column +ALTER TABLE llm_message +DROP COLUMN IF EXISTS deleted_at; + diff --git a/tim-db/queries/checkpoint.sql b/tim-db/queries/checkpoint.sql new file mode 100644 index 000000000..7f13a1500 --- /dev/null +++ b/tim-db/queries/checkpoint.sql @@ -0,0 +1,157 @@ +-- Checkpoint Queries + +-- name: CreateCheckpoint :one +INSERT INTO thread_checkpoint ( + thread_uid, + message_uid, + working_directory, + total_files, + total_size_bytes +) VALUES ( + sqlc.arg(thread_uid)::uuid, + sqlc.arg(message_uid)::uuid, + sqlc.arg(working_directory)::text, + sqlc.arg(total_files)::int, + sqlc.arg(total_size_bytes)::bigint +) RETURNING *; + +-- name: GetCheckpoint :one +SELECT * +FROM thread_checkpoint +WHERE uid = sqlc.arg(checkpoint_uid)::uuid; + +-- name: GetCheckpointByMessage :one +SELECT * +FROM thread_checkpoint +WHERE message_uid = sqlc.arg(message_uid)::uuid; + +-- name: GetCheckpointByThreadAndMessage :one +SELECT * +FROM thread_checkpoint +WHERE thread_uid = sqlc.arg(thread_uid)::uuid + AND message_uid = sqlc.arg(message_uid)::uuid; + +-- name: ListCheckpointsByThread :many +SELECT * +FROM thread_checkpoint +WHERE thread_uid = sqlc.arg(thread_uid)::uuid +ORDER BY create_time DESC +LIMIT sqlc.arg(page_limit)::int; + +-- name: DeleteCheckpoint :exec +DELETE FROM thread_checkpoint +WHERE uid = sqlc.arg(checkpoint_uid)::uuid; + +-- name: DeleteCheckpointsByThread :exec +DELETE FROM thread_checkpoint +WHERE thread_uid = sqlc.arg(thread_uid)::uuid; + +-- File Snapshot Queries + +-- name: CreateFileSnapshot :one +INSERT INTO checkpoint_file_snapshot ( + checkpoint_uid, + file_path, + is_base_snapshot, + content, + file_hash, + file_size, + file_mode, + modified_time, + is_deleted +) VALUES ( + sqlc.arg(checkpoint_uid)::uuid, + sqlc.arg(file_path)::text, + sqlc.arg(is_base_snapshot)::boolean, + sqlc.arg(content)::jsonb, + sqlc.narg(file_hash)::text, + sqlc.narg(file_size)::bigint, + sqlc.narg(file_mode)::int, + sqlc.narg(modified_time)::timestamptz, + sqlc.arg(is_deleted)::boolean +) RETURNING *; + +-- name: GetFileSnapshot :one +SELECT * +FROM checkpoint_file_snapshot +WHERE uid = sqlc.arg(snapshot_uid)::uuid; + +-- name: GetFileSnapshotByPath :one +SELECT * +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = sqlc.arg(checkpoint_uid)::uuid + AND file_path = sqlc.arg(file_path)::text; + +-- name: ListFileSnapshots :many +SELECT * +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = sqlc.arg(checkpoint_uid)::uuid +ORDER BY file_path ASC; + +-- name: GetBaseSnapshotForFile :one +-- Get the most recent base snapshot for a file before or at the given checkpoint +-- Used for reconstructing file content from diffs +SELECT cfs.* +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = sqlc.arg(thread_uid)::uuid + AND cfs.file_path = sqlc.arg(file_path)::text + AND cfs.is_base_snapshot = true + AND tc.create_time <= ( + SELECT create_time + FROM thread_checkpoint + WHERE uid = sqlc.arg(checkpoint_uid)::uuid + ) +ORDER BY tc.create_time DESC +LIMIT 1; + +-- name: ListDiffSnapshotsForFile :many +-- Get all diff snapshots for a file between base and target checkpoint +-- Used for reconstructing file content from diffs +SELECT cfs.* +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = sqlc.arg(thread_uid)::uuid + AND cfs.file_path = sqlc.arg(file_path)::text + AND cfs.is_base_snapshot = false + AND tc.create_time > sqlc.arg(base_checkpoint_time)::timestamptz + AND tc.create_time <= sqlc.arg(target_checkpoint_time)::timestamptz +ORDER BY tc.create_time ASC; + +-- name: GetLastFileSnapshot :one +-- Get the most recent snapshot (base or diff) for a file in a thread +-- Used to determine if file has changed since last checkpoint +SELECT cfs.* +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = sqlc.arg(thread_uid)::uuid + AND cfs.file_path = sqlc.arg(file_path)::text +ORDER BY tc.create_time DESC +LIMIT 1; + +-- name: CountFileSnapshotsByCheckpoint :one +SELECT COUNT(*) +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = sqlc.arg(checkpoint_uid)::uuid; + +-- name: GetCheckpointStats :one +-- Get checkpoint statistics (total files, total size) +SELECT + COUNT(*) as file_count, + COALESCE(SUM(file_size), 0) as total_size +FROM checkpoint_file_snapshot +WHERE checkpoint_uid = sqlc.arg(checkpoint_uid)::uuid + AND is_deleted = false; + +-- Thread Working Directory Queries + +-- name: UpdateThreadWorkingDirectory :exec +UPDATE thread +SET working_directory = sqlc.arg(working_directory)::text +WHERE uid = sqlc.arg(thread_uid)::uuid; + +-- name: GetThreadWorkingDirectory :one +SELECT working_directory +FROM thread +WHERE uid = sqlc.arg(thread_uid)::uuid; + diff --git a/tim-db/queries/thread.sql b/tim-db/queries/thread.sql index 3b242cc90..c1d2ee024 100644 --- a/tim-db/queries/thread.sql +++ b/tim-db/queries/thread.sql @@ -1,5 +1,5 @@ -- name: GetThread :one -SELECT +SELECT t.*, pr.persona_uid as persona_uid FROM thread t @@ -14,7 +14,7 @@ FROM thread WHERE uid = sqlc.arg(thread_uid)::uuid; -- name: ListThreads :many -SELECT +SELECT t.*, pr.persona_uid as persona_uid FROM thread t @@ -34,6 +34,13 @@ FROM thread WHERE owner_uid = sqlc.arg(owner_uid)::uuid AND organization_uid = sqlc.arg(organization_uid)::uuid; +-- name: DeleteMessagesAfterIndex :exec +UPDATE llm_message +SET deleted_at = NOW() +WHERE origin_thread_uid = sqlc.arg(thread_uid)::uuid + AND idx > sqlc.arg(idx)::int + AND deleted_at IS NULL; + -- name: CreateThread :one INSERT INTO thread ( parent_uid, @@ -72,12 +79,14 @@ SELECT llm_message.* FROM llm_message WHERE uid = sqlc.arg(message_uid)::uuid - AND origin_thread_uid = sqlc.arg(thread_uid)::uuid; + AND origin_thread_uid = sqlc.arg(thread_uid)::uuid + AND deleted_at IS NULL; -- name: GetMessageByUID :one SELECT * FROM llm_message -WHERE uid = sqlc.arg(message_uid)::uuid; +WHERE uid = sqlc.arg(message_uid)::uuid + AND deleted_at IS NULL; -- name: ListThreadMessages :many SELECT @@ -85,6 +94,7 @@ SELECT FROM llm_message INNER JOIN thread_message ON llm_message.uid = thread_message.message_uid WHERE thread_message.thread_uid = sqlc.arg(thread_uid)::uuid + AND llm_message.deleted_at IS NULL -- Cursor-based pagination AND (sqlc.narg(cursor_idx)::int IS NULL OR (llm_message.idx > sqlc.narg(cursor_idx)::int OR @@ -182,6 +192,7 @@ SELECT FROM llm_message INNER JOIN thread_message ON llm_message.uid = thread_message.message_uid WHERE thread_message.thread_uid = sqlc.arg(thread_uid)::uuid + AND llm_message.deleted_at IS NULL ORDER BY llm_message.idx DESC, llm_message.uid DESC LIMIT 1; diff --git a/tim-db/queries/tool_execution.sql b/tim-db/queries/tool_execution.sql index fb16b524b..68cea83fe 100644 --- a/tim-db/queries/tool_execution.sql +++ b/tim-db/queries/tool_execution.sql @@ -1,7 +1,7 @@ -- name: GetToolCallByID :one -- Find a tool call by its tool_call.id (from LLM, like "toolu_abc123") -- The tool_call_id is stored in the content JSONB field as {"id": "toolu_abc123", ...} -SELECT +SELECT mc.uid as content_uid, mc.content, mc.create_time, @@ -13,11 +13,12 @@ FROM llm_message_content mc JOIN llm_message m ON mc.message_uid = m.uid JOIN thread_message tm ON m.uid = tm.message_uid JOIN thread t ON tm.thread_uid = t.uid -WHERE +WHERE tm.thread_uid = sqlc.arg(thread_uid)::uuid AND t.organization_uid = sqlc.arg(organization_uid)::uuid AND t.owner_uid = sqlc.arg(owner_uid)::uuid AND m.role = 'assistant' + AND m.deleted_at IS NULL AND mc.type = 'tool_use' AND mc.stream_status = 'complete' AND mc.content->>'id' = sqlc.arg(tool_call_id)::text @@ -25,7 +26,7 @@ LIMIT 1; -- name: GetToolCallByIDInternal :one -- Find a tool call by its tool_call.id for internal use (no org or owner checks) -SELECT +SELECT mc.uid as content_uid, mc.content, mc.create_time, @@ -34,9 +35,10 @@ SELECT FROM llm_message_content mc JOIN llm_message m ON mc.message_uid = m.uid JOIN thread_message tm ON m.uid = tm.message_uid -WHERE +WHERE tm.thread_uid = sqlc.arg(thread_uid)::uuid AND m.role = 'assistant' + AND m.deleted_at IS NULL AND mc.type = 'tool_use' AND mc.stream_status = 'complete' AND mc.content->>'id' = sqlc.arg(tool_call_id)::text @@ -46,13 +48,14 @@ LIMIT 1; -- Check if a tool result already exists for a given tool_call_id -- Note: ToolResultContent uses "tool_use_id" field SELECT EXISTS( - SELECT 1 + SELECT 1 FROM llm_message_content result_mc JOIN llm_message result_m ON result_mc.message_uid = result_m.uid JOIN thread_message result_tm ON result_m.uid = result_tm.message_uid - WHERE + WHERE result_tm.thread_uid = sqlc.arg(thread_uid)::uuid AND result_m.role = 'user' + AND result_m.deleted_at IS NULL AND result_mc.type = 'tool_result' AND result_mc.stream_status = 'complete' AND result_mc.content->>'tool_use_id' = sqlc.arg(tool_call_id)::text @@ -67,6 +70,7 @@ FROM llm_message m JOIN thread_message tm ON m.uid = tm.message_uid WHERE tm.thread_uid = sqlc.arg(thread_uid)::uuid AND m.role = 'user' + AND m.deleted_at IS NULL AND m.idx > sqlc.arg(after_idx) AND m.origin_thread_uid = sqlc.arg(thread_uid)::uuid ORDER BY m.idx ASC @@ -103,7 +107,7 @@ WHERE message_uid = sqlc.arg(message_uid)::uuid -- name: CheckAnyToolResultHasStopIteration :one -- Check if any tool result in a specific message has stop_iteration=true SELECT EXISTS( - SELECT 1 + SELECT 1 FROM llm_message_content WHERE message_uid = sqlc.arg(message_uid)::uuid AND type = 'tool_result' diff --git a/tim-proto/gen/openapi.yaml b/tim-proto/gen/openapi.yaml index 777f030e0..a635eec7a 100644 --- a/tim-proto/gen/openapi.yaml +++ b/tim-proto/gen/openapi.yaml @@ -1587,6 +1587,56 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' + /v1alpha1/orgs/{org}/users/{user}/threads/{thread}/messages/{message}:edit: + post: + tags: + - ThreadService + description: Edit a thread message (with checkpoint restoration support) + operationId: ThreadService_EditThreadMessage + parameters: + - name: org + in: path + description: The org id. + required: true + schema: + type: string + - name: user + in: path + description: The user id. + required: true + schema: + type: string + - name: thread + in: path + description: The thread id. + required: true + schema: + type: string + - name: message + in: path + description: The message id. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EditThreadMessageRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/LlmMessage' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' /v1alpha1/orgs/{org}/users/{user}/threads/{thread}/messages/{message}:usage: post: tags: @@ -2007,6 +2057,47 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' + /v1alpha1/orgs/{org}/users/{user}/threads/{thread}:configureWorkingDirectory: + post: + tags: + - ThreadService + description: Configure the working directory for a thread (for checkpoint creation) + operationId: ThreadService_ConfigureThreadWorkingDirectory + parameters: + - name: org + in: path + description: The org id. + required: true + schema: + type: string + - name: user + in: path + description: The user id. + required: true + schema: + type: string + - name: thread + in: path + description: The thread id. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ConfigureThreadWorkingDirectoryRequest' + required: true + responses: + "200": + description: OK + content: {} + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' /v1alpha1/orgs/{org}/users/{user}/threads/{thread}:fork: post: tags: @@ -2324,6 +2415,18 @@ components: type: string description: Optional reason for cancellation description: CancelSubscriptionRequest is the request for canceling a subscription + ConfigureThreadWorkingDirectoryRequest: + required: + - path + type: object + properties: + path: + type: string + description: The resource path of the thread + workingDirectory: + type: string + description: The working directory path + description: ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation ConnectAppRequest: required: - parent @@ -2502,6 +2605,24 @@ components: type: string description: Stripe price ID description: CreditPack represents a purchasable credit package + EditThreadMessageRequest: + required: + - path + type: object + properties: + path: + type: string + description: The resource path of the message being updated. + content: + type: string + description: The new content for the message + confirmRestore: + type: boolean + description: |- + User confirmed checkpoint restoration (required if checkpoint exists with files) + If not provided and checkpoint restoration is needed, an error will be returned + with details about what will be restored. + description: EditThreadMessageRequest is used to edit a message (e.g., edit user message content) ExchangeAccessTokenRequest: required: - accessToken diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go index 77dde5196..9d0fd2e5f 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go +++ b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go @@ -13,6 +13,7 @@ import ( _ "google.golang.org/genproto/googleapis/api/annotations" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -885,11 +886,132 @@ func (x *UserMessage) GetText() string { return "" } +// EditThreadMessageRequest is used to edit a message (e.g., edit user message content) +type EditThreadMessageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resource path of the message being updated. + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // The new content for the message + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + // User confirmed checkpoint restoration (required if checkpoint exists with files) + // If not provided and checkpoint restoration is needed, an error will be returned + // with details about what will be restored. + ConfirmRestore bool `protobuf:"varint,3,opt,name=confirm_restore,json=confirmRestore,proto3" json:"confirm_restore,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditThreadMessageRequest) Reset() { + *x = EditThreadMessageRequest{} + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditThreadMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditThreadMessageRequest) ProtoMessage() {} + +func (x *EditThreadMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditThreadMessageRequest.ProtoReflect.Descriptor instead. +func (*EditThreadMessageRequest) Descriptor() ([]byte, []int) { + return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{14} +} + +func (x *EditThreadMessageRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EditThreadMessageRequest) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *EditThreadMessageRequest) GetConfirmRestore() bool { + if x != nil { + return x.ConfirmRestore + } + return false +} + +// ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation +type ConfigureThreadWorkingDirectoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resource path of the thread + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // The working directory path + WorkingDirectory string `protobuf:"bytes,2,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureThreadWorkingDirectoryRequest) Reset() { + *x = ConfigureThreadWorkingDirectoryRequest{} + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureThreadWorkingDirectoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureThreadWorkingDirectoryRequest) ProtoMessage() {} + +func (x *ConfigureThreadWorkingDirectoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigureThreadWorkingDirectoryRequest.ProtoReflect.Descriptor instead. +func (*ConfigureThreadWorkingDirectoryRequest) Descriptor() ([]byte, []int) { + return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{15} +} + +func (x *ConfigureThreadWorkingDirectoryRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ConfigureThreadWorkingDirectoryRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + var File_tim_api_thread_v1alpha1_thread_service_proto protoreflect.FileDescriptor const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\n" + - ",tim/api/thread/v1alpha1/thread_service.proto\x12\x17tim.api.thread.v1alpha1\x1a\x18aep/api/field_info.proto\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a*tim/api/thread/v1alpha1/thread_types.proto\x1a&tim/api/tool/v1alpha1/tool_types.proto\"\x9a\x01\n" + + ",tim/api/thread/v1alpha1/thread_service.proto\x12\x17tim.api.thread.v1alpha1\x1a\x18aep/api/field_info.proto\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a*tim/api/thread/v1alpha1/thread_types.proto\x1a&tim/api/tool/v1alpha1/tool_types.proto\"\x9a\x01\n" + "\x10GetThreadRequest\x12\x85\x01\n" + "\x04path\x18\x01 \x01(\tBq\xe0A\x02\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/threadR\x04path\"\xde\x01\n" + "\x12ListThreadsRequest\x12n\n" + @@ -945,7 +1067,15 @@ const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\x06parent\x18\x01 \x01(\tBq\xe0A\x02\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/threadR\x06parent\x12R\n" + "\fuser_message\x18\x02 \x01(\v2$.tim.api.thread.v1alpha1.UserMessageB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\vuserMessage\"1\n" + "\vUserMessage\x12\"\n" + - "\x04text\x18\x01 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\x04text2\xf0\f\n" + + "\x04text\x18\x01 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\x04text\"\x95\x02\n" + + "\x18EditThreadMessageRequest\x12\xa5\x01\n" + + "\x04path\x18\x01 \x01(\tB\x90\x01\xe0A\x02\xbaHerc2a^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}/messages/[a-fA-F0-9-]{36}$\u0091\x05!\x12\x1ftim.settlerlabs.com/llm-messageR\x04path\x12(\n" + + "\acontent\x18\x02 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\acontent\x12'\n" + + "\x0fconfirm_restore\x18\x03 \x01(\bR\x0econfirmRestore\"\xe9\x01\n" + + "&ConfigureThreadWorkingDirectoryRequest\x12\x85\x01\n" + + "\x04path\x18\x01 \x01(\tBq\xe0A\x02\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/threadR\x04path\x127\n" + + "\x11working_directory\x18\x02 \x01(\tB\n" + + "\xbaH\a\xc8\x01\x01r\x02\x10\x01R\x10workingDirectory2\x99\x10\n" + "\rThreadService\x12\x91\x01\n" + "\tGetThread\x12).tim.api.thread.v1alpha1.GetThreadRequest\x1a\x1f.tim.api.thread.v1alpha1.Thread\"8\xdaA\x04path\x82\xd3\xe4\x93\x02+\x12)/v1alpha1/{path=orgs/*/users/*/threads/*}\x12\xa4\x01\n" + "\vListThreads\x12+.tim.api.thread.v1alpha1.ListThreadsRequest\x1a,.tim.api.thread.v1alpha1.ListThreadsResponse\":\xdaA\x06parent\x82\xd3\xe4\x93\x02+\x12)/v1alpha1/{parent=orgs/*/users/*}/threads\x12\xb8\x01\n" + @@ -956,7 +1086,9 @@ const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\rGetLlmMessage\x12-.tim.api.thread.v1alpha1.GetLlmMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"C\xdaA\x04path\x82\xd3\xe4\x93\x026\x124/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}\x12\xbb\x01\n" + "\x0fListLlmMessages\x12/.tim.api.thread.v1alpha1.ListLlmMessagesRequest\x1a0.tim.api.thread.v1alpha1.ListLlmMessagesResponse\"E\xdaA\x06parent\x82\xd3\xe4\x93\x026\x124/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages\x12\xc4\x01\n" + "\x12StreamThreadEvents\x122.tim.api.thread.v1alpha1.StreamThreadEventsRequest\x1a3.tim.api.thread.v1alpha1.StreamThreadEventsResponse\"C\xdaA\x06parent\x82\xd3\xe4\x93\x024\x122/v1alpha1/{parent=orgs/*/users/*/threads/*}:stream0\x01\x12\xdf\x01\n" + - "\x11SubmitUserMessage\x121.tim.api.thread.v1alpha1.SubmitUserMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"r\xdaA\x13parent,user_message\x82\xd3\xe4\x93\x02V:\fuser_message\"F/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages:submitUserMessageB\x82\x02\n" + + "\x11SubmitUserMessage\x121.tim.api.thread.v1alpha1.SubmitUserMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"r\xdaA\x13parent,user_message\x82\xd3\xe4\x93\x02V:\fuser_message\"F/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages:submitUserMessage\x12\xc0\x01\n" + + "\x11EditThreadMessage\x121.tim.api.thread.v1alpha1.EditThreadMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"S\xdaA\fpath,content\x82\xd3\xe4\x93\x02>:\x01*\"9/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}:edit\x12\xe3\x01\n" + + "\x1fConfigureThreadWorkingDirectory\x12?.tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest\x1a\x16.google.protobuf.Empty\"g\xdaA\x16path,working_directory\x82\xd3\xe4\x93\x02H:\x01*\"C/v1alpha1/{path=orgs/*/users/*/threads/*}:configureWorkingDirectoryB\x82\x02\n" + "\x1bcom.tim.api.thread.v1alpha1B\x12ThreadServiceProtoP\x01ZPgithub.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1;threadv1alpha1\xa2\x02\x03TAT\xaa\x02\x17Tim.Api.Thread.V1alpha1\xca\x02\x17Tim\\Api\\Thread\\V1alpha1\xe2\x02#Tim\\Api\\Thread\\V1alpha1\\GPBMetadata\xea\x02\x1aTim::Api::Thread::V1alpha1b\x06proto3" var ( @@ -971,43 +1103,46 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP() []byte { return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescData } -var file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_tim_api_thread_v1alpha1_thread_service_proto_goTypes = []any{ - (*GetThreadRequest)(nil), // 0: tim.api.thread.v1alpha1.GetThreadRequest - (*ListThreadsRequest)(nil), // 1: tim.api.thread.v1alpha1.ListThreadsRequest - (*ListThreadsResponse)(nil), // 2: tim.api.thread.v1alpha1.ListThreadsResponse - (*CreateThreadRequest)(nil), // 3: tim.api.thread.v1alpha1.CreateThreadRequest - (*ForkThreadRequest)(nil), // 4: tim.api.thread.v1alpha1.ForkThreadRequest - (*ForkThreadResponse)(nil), // 5: tim.api.thread.v1alpha1.ForkThreadResponse - (*UpdateThreadRequest)(nil), // 6: tim.api.thread.v1alpha1.UpdateThreadRequest - (*GetLlmMessageRequest)(nil), // 7: tim.api.thread.v1alpha1.GetLlmMessageRequest - (*ListLlmMessagesRequest)(nil), // 8: tim.api.thread.v1alpha1.ListLlmMessagesRequest - (*ListLlmMessagesResponse)(nil), // 9: tim.api.thread.v1alpha1.ListLlmMessagesResponse - (*StreamThreadEventsRequest)(nil), // 10: tim.api.thread.v1alpha1.StreamThreadEventsRequest - (*StreamThreadEventsResponse)(nil), // 11: tim.api.thread.v1alpha1.StreamThreadEventsResponse - (*SubmitUserMessageRequest)(nil), // 12: tim.api.thread.v1alpha1.SubmitUserMessageRequest - (*UserMessage)(nil), // 13: tim.api.thread.v1alpha1.UserMessage - (*Thread)(nil), // 14: tim.api.thread.v1alpha1.Thread - (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp - (*LlmMessage)(nil), // 16: tim.api.thread.v1alpha1.LlmMessage - (*ContentStartEvent)(nil), // 17: tim.api.thread.v1alpha1.ContentStartEvent - (*ContentDeltaEvent)(nil), // 18: tim.api.thread.v1alpha1.ContentDeltaEvent - (*ContentStopEvent)(nil), // 19: tim.api.thread.v1alpha1.ContentStopEvent - (*v1alpha1.ToolCall)(nil), // 20: tim.api.tool.v1alpha1.ToolCall - (*ThreadStateChangeEvent)(nil), // 21: tim.api.thread.v1alpha1.ThreadStateChangeEvent + (*GetThreadRequest)(nil), // 0: tim.api.thread.v1alpha1.GetThreadRequest + (*ListThreadsRequest)(nil), // 1: tim.api.thread.v1alpha1.ListThreadsRequest + (*ListThreadsResponse)(nil), // 2: tim.api.thread.v1alpha1.ListThreadsResponse + (*CreateThreadRequest)(nil), // 3: tim.api.thread.v1alpha1.CreateThreadRequest + (*ForkThreadRequest)(nil), // 4: tim.api.thread.v1alpha1.ForkThreadRequest + (*ForkThreadResponse)(nil), // 5: tim.api.thread.v1alpha1.ForkThreadResponse + (*UpdateThreadRequest)(nil), // 6: tim.api.thread.v1alpha1.UpdateThreadRequest + (*GetLlmMessageRequest)(nil), // 7: tim.api.thread.v1alpha1.GetLlmMessageRequest + (*ListLlmMessagesRequest)(nil), // 8: tim.api.thread.v1alpha1.ListLlmMessagesRequest + (*ListLlmMessagesResponse)(nil), // 9: tim.api.thread.v1alpha1.ListLlmMessagesResponse + (*StreamThreadEventsRequest)(nil), // 10: tim.api.thread.v1alpha1.StreamThreadEventsRequest + (*StreamThreadEventsResponse)(nil), // 11: tim.api.thread.v1alpha1.StreamThreadEventsResponse + (*SubmitUserMessageRequest)(nil), // 12: tim.api.thread.v1alpha1.SubmitUserMessageRequest + (*UserMessage)(nil), // 13: tim.api.thread.v1alpha1.UserMessage + (*EditThreadMessageRequest)(nil), // 14: tim.api.thread.v1alpha1.EditThreadMessageRequest + (*ConfigureThreadWorkingDirectoryRequest)(nil), // 15: tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest + (*Thread)(nil), // 16: tim.api.thread.v1alpha1.Thread + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (*LlmMessage)(nil), // 18: tim.api.thread.v1alpha1.LlmMessage + (*ContentStartEvent)(nil), // 19: tim.api.thread.v1alpha1.ContentStartEvent + (*ContentDeltaEvent)(nil), // 20: tim.api.thread.v1alpha1.ContentDeltaEvent + (*ContentStopEvent)(nil), // 21: tim.api.thread.v1alpha1.ContentStopEvent + (*v1alpha1.ToolCall)(nil), // 22: tim.api.tool.v1alpha1.ToolCall + (*ThreadStateChangeEvent)(nil), // 23: tim.api.thread.v1alpha1.ThreadStateChangeEvent + (*emptypb.Empty)(nil), // 24: google.protobuf.Empty } var file_tim_api_thread_v1alpha1_thread_service_proto_depIdxs = []int32{ - 14, // 0: tim.api.thread.v1alpha1.ListThreadsResponse.results:type_name -> tim.api.thread.v1alpha1.Thread - 14, // 1: tim.api.thread.v1alpha1.CreateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread - 15, // 2: tim.api.thread.v1alpha1.ForkThreadRequest.before_time:type_name -> google.protobuf.Timestamp - 14, // 3: tim.api.thread.v1alpha1.ForkThreadResponse.thread:type_name -> tim.api.thread.v1alpha1.Thread - 14, // 4: tim.api.thread.v1alpha1.UpdateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread - 16, // 5: tim.api.thread.v1alpha1.ListLlmMessagesResponse.results:type_name -> tim.api.thread.v1alpha1.LlmMessage - 17, // 6: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_start:type_name -> tim.api.thread.v1alpha1.ContentStartEvent - 18, // 7: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_delta:type_name -> tim.api.thread.v1alpha1.ContentDeltaEvent - 19, // 8: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_stop:type_name -> tim.api.thread.v1alpha1.ContentStopEvent - 20, // 9: tim.api.thread.v1alpha1.StreamThreadEventsResponse.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall - 21, // 10: tim.api.thread.v1alpha1.StreamThreadEventsResponse.thread_state_change:type_name -> tim.api.thread.v1alpha1.ThreadStateChangeEvent + 16, // 0: tim.api.thread.v1alpha1.ListThreadsResponse.results:type_name -> tim.api.thread.v1alpha1.Thread + 16, // 1: tim.api.thread.v1alpha1.CreateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread + 17, // 2: tim.api.thread.v1alpha1.ForkThreadRequest.before_time:type_name -> google.protobuf.Timestamp + 16, // 3: tim.api.thread.v1alpha1.ForkThreadResponse.thread:type_name -> tim.api.thread.v1alpha1.Thread + 16, // 4: tim.api.thread.v1alpha1.UpdateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread + 18, // 5: tim.api.thread.v1alpha1.ListLlmMessagesResponse.results:type_name -> tim.api.thread.v1alpha1.LlmMessage + 19, // 6: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_start:type_name -> tim.api.thread.v1alpha1.ContentStartEvent + 20, // 7: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_delta:type_name -> tim.api.thread.v1alpha1.ContentDeltaEvent + 21, // 8: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_stop:type_name -> tim.api.thread.v1alpha1.ContentStopEvent + 22, // 9: tim.api.thread.v1alpha1.StreamThreadEventsResponse.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall + 23, // 10: tim.api.thread.v1alpha1.StreamThreadEventsResponse.thread_state_change:type_name -> tim.api.thread.v1alpha1.ThreadStateChangeEvent 13, // 11: tim.api.thread.v1alpha1.SubmitUserMessageRequest.user_message:type_name -> tim.api.thread.v1alpha1.UserMessage 0, // 12: tim.api.thread.v1alpha1.ThreadService.GetThread:input_type -> tim.api.thread.v1alpha1.GetThreadRequest 1, // 13: tim.api.thread.v1alpha1.ThreadService.ListThreads:input_type -> tim.api.thread.v1alpha1.ListThreadsRequest @@ -1018,17 +1153,21 @@ var file_tim_api_thread_v1alpha1_thread_service_proto_depIdxs = []int32{ 8, // 18: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:input_type -> tim.api.thread.v1alpha1.ListLlmMessagesRequest 10, // 19: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:input_type -> tim.api.thread.v1alpha1.StreamThreadEventsRequest 12, // 20: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:input_type -> tim.api.thread.v1alpha1.SubmitUserMessageRequest - 14, // 21: tim.api.thread.v1alpha1.ThreadService.GetThread:output_type -> tim.api.thread.v1alpha1.Thread - 2, // 22: tim.api.thread.v1alpha1.ThreadService.ListThreads:output_type -> tim.api.thread.v1alpha1.ListThreadsResponse - 14, // 23: tim.api.thread.v1alpha1.ThreadService.CreateThread:output_type -> tim.api.thread.v1alpha1.Thread - 5, // 24: tim.api.thread.v1alpha1.ThreadService.ForkThread:output_type -> tim.api.thread.v1alpha1.ForkThreadResponse - 14, // 25: tim.api.thread.v1alpha1.ThreadService.UpdateThread:output_type -> tim.api.thread.v1alpha1.Thread - 16, // 26: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage - 9, // 27: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:output_type -> tim.api.thread.v1alpha1.ListLlmMessagesResponse - 11, // 28: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:output_type -> tim.api.thread.v1alpha1.StreamThreadEventsResponse - 16, // 29: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage - 21, // [21:30] is the sub-list for method output_type - 12, // [12:21] is the sub-list for method input_type + 14, // 21: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:input_type -> tim.api.thread.v1alpha1.EditThreadMessageRequest + 15, // 22: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:input_type -> tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest + 16, // 23: tim.api.thread.v1alpha1.ThreadService.GetThread:output_type -> tim.api.thread.v1alpha1.Thread + 2, // 24: tim.api.thread.v1alpha1.ThreadService.ListThreads:output_type -> tim.api.thread.v1alpha1.ListThreadsResponse + 16, // 25: tim.api.thread.v1alpha1.ThreadService.CreateThread:output_type -> tim.api.thread.v1alpha1.Thread + 5, // 26: tim.api.thread.v1alpha1.ThreadService.ForkThread:output_type -> tim.api.thread.v1alpha1.ForkThreadResponse + 16, // 27: tim.api.thread.v1alpha1.ThreadService.UpdateThread:output_type -> tim.api.thread.v1alpha1.Thread + 18, // 28: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage + 9, // 29: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:output_type -> tim.api.thread.v1alpha1.ListLlmMessagesResponse + 11, // 30: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:output_type -> tim.api.thread.v1alpha1.StreamThreadEventsResponse + 18, // 31: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage + 18, // 32: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage + 24, // 33: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:output_type -> google.protobuf.Empty + 23, // [23:34] is the sub-list for method output_type + 12, // [12:23] is the sub-list for method input_type 12, // [12:12] is the sub-list for extension type_name 12, // [12:12] is the sub-list for extension extendee 0, // [0:12] is the sub-list for field type_name @@ -1053,7 +1192,7 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc), len(file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 14, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json index bb779603f..b1e94495e 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json +++ b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json @@ -381,6 +381,89 @@ "ThreadService" ] } + }, + "/v1alpha1/{path}:configureWorkingDirectory": { + "post": { + "summary": "Configure the working directory for a thread (for checkpoint creation)", + "operationId": "ThreadService_ConfigureThreadWorkingDirectory", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": {} + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "path", + "description": "The resource path of the thread", + "in": "path", + "required": true, + "type": "string", + "pattern": "orgs/[^/]+/users/[^/]+/threads/[^/]+" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ThreadServiceConfigureThreadWorkingDirectoryBody" + } + } + ], + "tags": [ + "ThreadService" + ] + } + }, + "/v1alpha1/{path}:edit": { + "post": { + "summary": "Edit a thread message (with checkpoint restoration support)", + "operationId": "ThreadService_EditThreadMessage", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1LlmMessage" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "path", + "description": "The resource path of the message being updated.", + "in": "path", + "required": true, + "type": "string", + "pattern": "orgs/[^/]+/users/[^/]+/threads/[^/]+/messages/[^/]+" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ThreadServiceEditThreadMessageBody" + } + } + ], + "tags": [ + "ThreadService" + ] + } } }, "definitions": { @@ -399,6 +482,16 @@ }, "description": "The identifier for the original thread this thread was forked from." }, + "ThreadServiceConfigureThreadWorkingDirectoryBody": { + "type": "object", + "properties": { + "workingDirectory": { + "type": "string", + "title": "The working directory path" + } + }, + "title": "ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation" + }, "ThreadServiceCreateThreadBody": { "type": "object", "properties": { @@ -416,6 +509,20 @@ "thread" ] }, + "ThreadServiceEditThreadMessageBody": { + "type": "object", + "properties": { + "content": { + "type": "string", + "title": "The new content for the message" + }, + "confirmRestore": { + "type": "boolean", + "description": "User confirmed checkpoint restoration (required if checkpoint exists with files)\nIf not provided and checkpoint restoration is needed, an error will be returned\nwith details about what will be restored." + } + }, + "title": "EditThreadMessageRequest is used to edit a message (e.g., edit user message content)" + }, "ThreadServiceForkThreadBody": { "type": "object", "properties": { diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go b/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go index 4ec549fc7..9e849c2cd 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go +++ b/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go @@ -9,6 +9,7 @@ import ( context "context" errors "errors" v1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1" + emptypb "google.golang.org/protobuf/types/known/emptypb" http "net/http" strings "strings" ) @@ -59,6 +60,12 @@ const ( // ThreadServiceSubmitUserMessageProcedure is the fully-qualified name of the ThreadService's // SubmitUserMessage RPC. ThreadServiceSubmitUserMessageProcedure = "/tim.api.thread.v1alpha1.ThreadService/SubmitUserMessage" + // ThreadServiceEditThreadMessageProcedure is the fully-qualified name of the ThreadService's + // EditThreadMessage RPC. + ThreadServiceEditThreadMessageProcedure = "/tim.api.thread.v1alpha1.ThreadService/EditThreadMessage" + // ThreadServiceConfigureThreadWorkingDirectoryProcedure is the fully-qualified name of the + // ThreadService's ConfigureThreadWorkingDirectory RPC. + ThreadServiceConfigureThreadWorkingDirectoryProcedure = "/tim.api.thread.v1alpha1.ThreadService/ConfigureThreadWorkingDirectory" ) // ThreadServiceClient is a client for the tim.api.thread.v1alpha1.ThreadService service. @@ -83,6 +90,10 @@ type ThreadServiceClient interface { StreamThreadEvents(context.Context, *connect.Request[v1alpha1.StreamThreadEventsRequest]) (*connect.ServerStreamForClient[v1alpha1.StreamThreadEventsResponse], error) // Submit a user message to a thread SubmitUserMessage(context.Context, *connect.Request[v1alpha1.SubmitUserMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + // Edit a thread message (with checkpoint restoration support) + EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + // Configure the working directory for a thread (for checkpoint creation) + ConfigureThreadWorkingDirectory(context.Context, *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) } // NewThreadServiceClient constructs a client for the tim.api.thread.v1alpha1.ThreadService service. @@ -150,20 +161,34 @@ func NewThreadServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(threadServiceMethods.ByName("SubmitUserMessage")), connect.WithClientOptions(opts...), ), + editThreadMessage: connect.NewClient[v1alpha1.EditThreadMessageRequest, v1alpha1.LlmMessage]( + httpClient, + baseURL+ThreadServiceEditThreadMessageProcedure, + connect.WithSchema(threadServiceMethods.ByName("EditThreadMessage")), + connect.WithClientOptions(opts...), + ), + configureThreadWorkingDirectory: connect.NewClient[v1alpha1.ConfigureThreadWorkingDirectoryRequest, emptypb.Empty]( + httpClient, + baseURL+ThreadServiceConfigureThreadWorkingDirectoryProcedure, + connect.WithSchema(threadServiceMethods.ByName("ConfigureThreadWorkingDirectory")), + connect.WithClientOptions(opts...), + ), } } // threadServiceClient implements ThreadServiceClient. type threadServiceClient struct { - getThread *connect.Client[v1alpha1.GetThreadRequest, v1alpha1.Thread] - listThreads *connect.Client[v1alpha1.ListThreadsRequest, v1alpha1.ListThreadsResponse] - createThread *connect.Client[v1alpha1.CreateThreadRequest, v1alpha1.Thread] - forkThread *connect.Client[v1alpha1.ForkThreadRequest, v1alpha1.ForkThreadResponse] - updateThread *connect.Client[v1alpha1.UpdateThreadRequest, v1alpha1.Thread] - getLlmMessage *connect.Client[v1alpha1.GetLlmMessageRequest, v1alpha1.LlmMessage] - listLlmMessages *connect.Client[v1alpha1.ListLlmMessagesRequest, v1alpha1.ListLlmMessagesResponse] - streamThreadEvents *connect.Client[v1alpha1.StreamThreadEventsRequest, v1alpha1.StreamThreadEventsResponse] - submitUserMessage *connect.Client[v1alpha1.SubmitUserMessageRequest, v1alpha1.LlmMessage] + getThread *connect.Client[v1alpha1.GetThreadRequest, v1alpha1.Thread] + listThreads *connect.Client[v1alpha1.ListThreadsRequest, v1alpha1.ListThreadsResponse] + createThread *connect.Client[v1alpha1.CreateThreadRequest, v1alpha1.Thread] + forkThread *connect.Client[v1alpha1.ForkThreadRequest, v1alpha1.ForkThreadResponse] + updateThread *connect.Client[v1alpha1.UpdateThreadRequest, v1alpha1.Thread] + getLlmMessage *connect.Client[v1alpha1.GetLlmMessageRequest, v1alpha1.LlmMessage] + listLlmMessages *connect.Client[v1alpha1.ListLlmMessagesRequest, v1alpha1.ListLlmMessagesResponse] + streamThreadEvents *connect.Client[v1alpha1.StreamThreadEventsRequest, v1alpha1.StreamThreadEventsResponse] + submitUserMessage *connect.Client[v1alpha1.SubmitUserMessageRequest, v1alpha1.LlmMessage] + editThreadMessage *connect.Client[v1alpha1.EditThreadMessageRequest, v1alpha1.LlmMessage] + configureThreadWorkingDirectory *connect.Client[v1alpha1.ConfigureThreadWorkingDirectoryRequest, emptypb.Empty] } // GetThread calls tim.api.thread.v1alpha1.ThreadService.GetThread. @@ -211,6 +236,17 @@ func (c *threadServiceClient) SubmitUserMessage(ctx context.Context, req *connec return c.submitUserMessage.CallUnary(ctx, req) } +// EditThreadMessage calls tim.api.thread.v1alpha1.ThreadService.EditThreadMessage. +func (c *threadServiceClient) EditThreadMessage(ctx context.Context, req *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) { + return c.editThreadMessage.CallUnary(ctx, req) +} + +// ConfigureThreadWorkingDirectory calls +// tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory. +func (c *threadServiceClient) ConfigureThreadWorkingDirectory(ctx context.Context, req *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) { + return c.configureThreadWorkingDirectory.CallUnary(ctx, req) +} + // ThreadServiceHandler is an implementation of the tim.api.thread.v1alpha1.ThreadService service. type ThreadServiceHandler interface { // Get a thread by ID @@ -233,6 +269,10 @@ type ThreadServiceHandler interface { StreamThreadEvents(context.Context, *connect.Request[v1alpha1.StreamThreadEventsRequest], *connect.ServerStream[v1alpha1.StreamThreadEventsResponse]) error // Submit a user message to a thread SubmitUserMessage(context.Context, *connect.Request[v1alpha1.SubmitUserMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + // Edit a thread message (with checkpoint restoration support) + EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + // Configure the working directory for a thread (for checkpoint creation) + ConfigureThreadWorkingDirectory(context.Context, *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) } // NewThreadServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -296,6 +336,18 @@ func NewThreadServiceHandler(svc ThreadServiceHandler, opts ...connect.HandlerOp connect.WithSchema(threadServiceMethods.ByName("SubmitUserMessage")), connect.WithHandlerOptions(opts...), ) + threadServiceEditThreadMessageHandler := connect.NewUnaryHandler( + ThreadServiceEditThreadMessageProcedure, + svc.EditThreadMessage, + connect.WithSchema(threadServiceMethods.ByName("EditThreadMessage")), + connect.WithHandlerOptions(opts...), + ) + threadServiceConfigureThreadWorkingDirectoryHandler := connect.NewUnaryHandler( + ThreadServiceConfigureThreadWorkingDirectoryProcedure, + svc.ConfigureThreadWorkingDirectory, + connect.WithSchema(threadServiceMethods.ByName("ConfigureThreadWorkingDirectory")), + connect.WithHandlerOptions(opts...), + ) return "/tim.api.thread.v1alpha1.ThreadService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ThreadServiceGetThreadProcedure: @@ -316,6 +368,10 @@ func NewThreadServiceHandler(svc ThreadServiceHandler, opts ...connect.HandlerOp threadServiceStreamThreadEventsHandler.ServeHTTP(w, r) case ThreadServiceSubmitUserMessageProcedure: threadServiceSubmitUserMessageHandler.ServeHTTP(w, r) + case ThreadServiceEditThreadMessageProcedure: + threadServiceEditThreadMessageHandler.ServeHTTP(w, r) + case ThreadServiceConfigureThreadWorkingDirectoryProcedure: + threadServiceConfigureThreadWorkingDirectoryHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -360,3 +416,11 @@ func (UnimplementedThreadServiceHandler) StreamThreadEvents(context.Context, *co func (UnimplementedThreadServiceHandler) SubmitUserMessage(context.Context, *connect.Request[v1alpha1.SubmitUserMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage is not implemented")) } + +func (UnimplementedThreadServiceHandler) EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("tim.api.thread.v1alpha1.ThreadService.EditThreadMessage is not implemented")) +} + +func (UnimplementedThreadServiceHandler) ConfigureThreadWorkingDirectory(context.Context, *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory is not implemented")) +} From 7512925c77186377bae238d060ef30dff477ff41 Mon Sep 17 00:00:00 2001 From: ethanoroshiba Date: Wed, 5 Nov 2025 16:03:46 -0600 Subject: [PATCH 2/4] e2e test --- .github/workflows/e2e-tests.yml | 7 + tests/system/framework/api_client.py | 35 +++ tests/system/framework/services.py | 4 +- tests/system/test_thread_checkpoint.py | 365 +++++++++++++++++++++++++ 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 tests/system/test_thread_checkpoint.py diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6bea6cbb6..075ad7978 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -103,6 +103,12 @@ jobs: test-name: "billing" secrets: inherit + e2e-test-thread-checkpoint: + uses: ./.github/workflows/reusable-e2e-test.yml + with: + test-name: "thread-checkpoint" + secrets: inherit + e2e-tests-complete: needs: [ @@ -116,6 +122,7 @@ jobs: e2e-test-thread-state-validation, e2e-test-connection-drops, e2e-test-billing, + e2e-test-thread-checkpoint, ] if: ${{ always() && !cancelled() }} uses: ./.github/workflows/reusable-success.yml diff --git a/tests/system/framework/api_client.py b/tests/system/framework/api_client.py index 1a7158b5b..d57d4b16c 100644 --- a/tests/system/framework/api_client.py +++ b/tests/system/framework/api_client.py @@ -232,6 +232,41 @@ def submit_user_message(self, thread_path: str, text: str) -> dict[str, Any]: response.raise_for_status() return response.json() + def edit_thread_message( + self, message_path: str, content: str, confirm_restore: bool = False + ) -> dict[str, Any]: + """Edit a thread message. + + Args: + message_path: Path to the message (e.g., "orgs/{org}/users/{user}/threads/{thread}/messages/{message}") + content: New content for the message + confirm_restore: If true, confirms checkpoint restoration + + Returns: + The updated message + """ + response = self.client.post( + self._url(f"{message_path}:edit"), + headers=self._default_headers, + json={"content": content, "confirm_restore": confirm_restore}, + ) + response.raise_for_status() + return response.json() + + def configure_thread_working_directory(self, thread_path: str, working_directory: str) -> None: + """Configure the working directory for a thread. + + Args: + thread_path: Path to the thread + working_directory: Absolute path to the working directory + """ + response = self.client.post( + self._url(f"{thread_path}:configureWorkingDirectory"), + headers=self._default_headers, + json={"working_directory": working_directory}, + ) + response.raise_for_status() + def submit_tool_result( self, thread_path: str, diff --git a/tests/system/framework/services.py b/tests/system/framework/services.py index 71522885a..1e02bd5aa 100644 --- a/tests/system/framework/services.py +++ b/tests/system/framework/services.py @@ -270,8 +270,8 @@ def _build_worker_env(self) -> dict[str, str]: required_api_keys = [ "TIM_WORKER_ANTHROPIC_API_KEY", "TIM_WORKER_OPENAI_API_KEY", - "STRIPE_SECRET_KEY", - "STRIPE_WEBHOOK_SECRET", + # "STRIPE_SECRET_KEY", + # "STRIPE_WEBHOOK_SECRET", ] print(env) diff --git a/tests/system/test_thread_checkpoint.py b/tests/system/test_thread_checkpoint.py new file mode 100644 index 000000000..67f1be67b --- /dev/null +++ b/tests/system/test_thread_checkpoint.py @@ -0,0 +1,365 @@ +"""Thread checkpoint system test. + +Tests checkpoint creation and restoration: + 1. Create org, user, and persona + 2. Create thread with initial message + 3. Configure working directory for checkpoint tracking + 4. Submit messages to trigger checkpoint creation + 5. Edit a message to trigger checkpoint restoration + 6. Verify restoration requires confirmation + 7. Verify restoration deletes subsequent messages + +Checkpoint Feature Overview: +- Checkpoints capture the state of a working directory after message completion +- When editing a message, the system can restore files to their state at that checkpoint +- Restoration requires explicit confirmation if files will be modified +- All messages after the edited message are deleted + +Running this test: + # From the workspace root + just testing::test-system-one thread-checkpoint + + # Or with specific model + just testing::test-system-one thread-checkpoint -- --model-id=claude-4-5-sonnet + + # Or directly with pytest from tests/system directory + cd tests/system + uv run pytest -v -s test_thread_checkpoint.py +""" + +import tempfile +import threading +import time +from pathlib import Path + +import httpx + +from framework.api_client import TimApiClient +from framework.models import Org, User +from framework.polling import ThreadPoller +from framework.streaming_helpers import StreamCollector, stream_events_background + +SEPARATOR = "=" * 40 + + +def create_test_files(working_dir: Path) -> dict[str, str]: + """Create test files in the working directory. + + Args: + working_dir: Path to the working directory + + Returns: + Dictionary mapping file paths to their content + """ + files = { + "test1.txt": "Hello World\nLine 2\nLine 3\n", + "test2.txt": "Another file\nWith content\n", + "subdir/test3.txt": "Nested file\nIn subdirectory\n", + } + + for file_path, content in files.items(): + full_path = working_dir / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + + return files + + +def modify_test_files(working_dir: Path) -> dict[str, str]: + """Modify test files to create different checkpoint state. + + Args: + working_dir: Path to the working directory + + Returns: + Dictionary mapping file paths to their new content + """ + files = { + "test1.txt": "Modified content\nLine 2\nNew line 3\n", + "test2.txt": "Another file\nWith modified content\nAnd a new line\n", + "test4.txt": "A new file\nCreated later\n", + } + + for file_path, content in files.items(): + full_path = working_dir / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + + return files + + +def test_thread_checkpoint_basic( + api_client: TimApiClient, + thread_poller: ThreadPoller, + test_org: Org, + test_user: User, + model_id: str, + test_summary, +): + """Test basic checkpoint creation and restoration flow. + + This test verifies: + 1. Checkpoint creation after message completion (when working directory is configured) + 2. File state tracking across multiple checkpoints + 3. Edit-without-confirmation fails with FailedPrecondition error + 4. Edit-with-confirmation succeeds and triggers restoration + 5. Subsequent messages are deleted after restoration + 6. File restoration uses file_write tool calls + + The test creates multiple messages, modifies files between messages, then edits + an earlier message to verify checkpoint restoration behavior. + """ + test_summary.org = test_org + test_summary.user = test_user + + # Create temporary working directory + with tempfile.TemporaryDirectory() as temp_dir: + working_dir = Path(temp_dir) + print(f"\nUsing working directory: {working_dir}") + + # Create initial test files + print("\nCreating initial test files...") + initial_files = create_test_files(working_dir) + print(f" Created {len(initial_files)} files") + + # Create persona with work_complete tool (to avoid infinite loops) + persona = api_client.create_persona( + test_org.org_id, + test_user.user_id, + display_name="Checkpoint Test Persona", + description="Test persona for checkpoint testing", + ) + test_summary.persona = persona + + # Create persona revision without thinking (simpler for checkpoint testing) + tools = ["work_complete"] + test_summary.tools = tools + revision = api_client.create_persona_revision( + persona.path, + system_prompt=( + "You are a helpful AI assistant. Provide brief responses. " + "When you finish responding, call work_complete." + ), + tools=tools, + model_id=model_id, + tool_choice="auto", + use_thinking=False, + ) + test_summary.persona_revision = revision + + # Finalize persona revision + api_client.finalize_persona_revision(revision.path) + + # Create thread with initial message + print("\nCreating thread with initial message...") + thread = api_client.create_thread( + test_org.org_id, + test_user.user_id, + persona.persona_id, + display_name="Checkpoint Test Thread", + initial_message_text="Hello, this is the first message", + ) + print(f" Created thread: {thread.thread_id}") + test_summary.thread = thread + + # Configure working directory for checkpoint tracking + print(f"\nConfiguring working directory: {working_dir}") + api_client.configure_thread_working_directory(thread.path, str(working_dir)) + print(" ✓ Working directory configured") + + # Set up event collector for streaming + collector = StreamCollector() + + # Start streaming in background thread + print("\nStarting event stream capture...") + stream_thread = threading.Thread( + target=stream_events_background, + args=(api_client, thread.path, collector), + kwargs={"timeout": 300.0, "verbose": False}, + daemon=True, + ) + stream_thread.start() + + # Wait for stream to start + if not collector.stream_started.wait(timeout=5.0): + raise RuntimeError("Stream failed to start") + time.sleep(1) + print(" ✓ Stream connected") + + # Wait for initial message to complete (first IDLE) + print("\nWaiting for initial message to complete...") + max_wait = 60 + start_time = time.time() + while time.time() - start_time < max_wait: + if collector.has_idle_state(): + break + time.sleep(1) + + if not collector.has_idle_state(): + raise AssertionError(f"Thread did not become IDLE within {max_wait} seconds") + print(" ✓ Initial message completed (checkpoint 1 should be created)") + + # Get initial messages to track message UIDs + messages = api_client.list_messages(thread.path) + initial_message_count = len(messages.results) + print(f" Messages after initial: {initial_message_count}") + + # First user message UID (will be the one we edit later) + first_user_message = messages.results[0] # Initial user message + first_message_path = first_user_message.path + first_message_uid = first_user_message.uid + print(f" First message UID: {first_message_uid}") + + # Submit second user message + print("\nSubmitting second user message...") + message2_response = api_client.submit_user_message( + thread.path, "This is the second message" + ) + message2_uid = message2_response.get("uid", "") + print(f" ✓ Message 2 submitted: {message2_uid}") + + # Wait for second message to complete (second IDLE) + print("\nWaiting for second message to complete...") + start_time = time.time() + while time.time() - start_time < max_wait: + if collector.count_idle_states() >= 2: + break + time.sleep(1) + + if collector.count_idle_states() < 2: + raise AssertionError("Thread did not return to IDLE after second message") + print(" ✓ Second message completed (checkpoint 2 should be created)") + + # Modify files to create different state + print("\nModifying files for checkpoint 2...") + modify_test_files(working_dir) + print(" ✓ Files modified") + + # Submit third user message + print("\nSubmitting third user message...") + message3_response = api_client.submit_user_message(thread.path, "This is the third message") + message3_uid = message3_response.get("uid", "") + print(f" ✓ Message 3 submitted: {message3_uid}") + + # Wait for third message to complete (third IDLE) + print("\nWaiting for third message to complete...") + start_time = time.time() + while time.time() - start_time < max_wait: + if collector.count_idle_states() >= 3: + break + time.sleep(1) + + if collector.count_idle_states() < 3: + raise AssertionError("Thread did not return to IDLE after third message") + print(" ✓ Third message completed (checkpoint 3 should be created)") + + # Get message count before edit + messages_before_edit = api_client.list_messages(thread.path) + message_count_before = len(messages_before_edit.results) + print(f"\nMessages before edit: {message_count_before}") + + # Test 1: Try to edit first message WITHOUT confirm_restore + # This should fail with FailedPrecondition if checkpoint exists + print("\nTest 1: Attempting to edit without confirmation...") + try: + api_client.edit_thread_message( + first_message_path, + "This is the EDITED first message", + confirm_restore=False, + ) + # If we get here, either no checkpoint exists or the API isn't working + print(" ⚠ Edit succeeded without confirmation (no checkpoint or API issue)") + except httpx.HTTPStatusError as e: + # Expecting FAILED_PRECONDITION (HTTP 400 or similar) + if e.response.status_code in [400, 412]: # FailedPrecondition + print(" ✓ Edit rejected without confirmation (as expected)") + error_body = e.response.text + print(f" Error message: {error_body[:200]}...") + # Verify error message mentions checkpoint restoration + assert "checkpoint restoration required" in error_body.lower(), ( + f"Error message should mention checkpoint restoration, got: {error_body[:200]}" + ) + else: + raise + + # Test 2: Edit first message WITH confirm_restore + print("\nTest 2: Editing message with confirmation...") + try: + edited_message = api_client.edit_thread_message( + first_message_path, + "This is the EDITED first message", + confirm_restore=True, + ) + print(" ✓ Message edited successfully") + print(f" Edited message UID: {edited_message.get('uid', '')}") + except httpx.HTTPStatusError as e: + print(f" ✗ Edit failed: {e.response.status_code} - {e.response.text[:200]}") + raise + + # Wait a moment for processing + time.sleep(2) + + # Verify messages after edit + print("\nVerifying messages after edit...") + messages_after_edit = api_client.list_messages(thread.path) + message_count_after = len(messages_after_edit.results) + print(f" Messages before edit: {message_count_before}") + print(f" Messages after edit: {message_count_after}") + + # Should have fewer messages (message 2, message 3, and their responses deleted) + # We expect: edited message 1, assistant response 1 (with file_write tool calls for restoration) + assert message_count_after < message_count_before, ( + f"Message count should decrease after edit (before: {message_count_before}, after: {message_count_after})" + ) + print( + f" ✓ Messages deleted as expected (deleted {message_count_before - message_count_after} messages)" + ) + + # Verify the edited message content + edited_messages = [m for m in messages_after_edit.results if m.uid == first_message_uid] + if edited_messages: + edited_msg = edited_messages[0] + # Get text from the message + msg_text = " ".join(c.text for c in edited_msg.contents if c.text) + assert "EDITED" in msg_text, f"Edited message should contain 'EDITED', got: {msg_text}" + print(" ✓ Message content updated correctly") + else: + print(" ⚠ Could not find edited message in results") + + # Check for file_write tool calls in the restoration message + print("\nChecking for file restoration tool calls...") + has_file_writes = False + for msg in messages_after_edit.results: + if msg.role.value == "LLM_MESSAGE_ROLE_ASSISTANT": + for content in msg.contents: + if content.tool_call and content.tool_call.name == "file_write": + has_file_writes = True + print(f" Found file_write tool call: {content.tool_call.path}") + + if has_file_writes: + print(" ✓ Checkpoint restoration tool calls found") + else: + print(" ⚠ No file_write tool calls found (checkpoint may be empty or not created)") + + # Print summary + print("") + print(SEPARATOR) + print("Thread Checkpoint Test PASSED") + print(SEPARATOR) + print("") + print("Summary:") + print(f" Thread: {thread.path}") + print(f" Working directory: {working_dir}") + print(f" Messages before edit: {message_count_before}") + print(f" Messages after edit: {message_count_after}") + print(f" Messages deleted: {message_count_before - message_count_after}") + print("") + + # Add extra info to summary + test_summary.extra_info["Working directory"] = str(working_dir) + test_summary.extra_info["Messages before edit"] = str(message_count_before) + test_summary.extra_info["Messages after edit"] = str(message_count_after) + test_summary.extra_info["Messages deleted"] = str( + message_count_before - message_count_after + ) + test_summary.extra_info["File restoration tool calls"] = "Yes" if has_file_writes else "No" From 67b487c9b544a88177c8220a895c159802a0ca3c Mon Sep 17 00:00:00 2001 From: ethanoroshiba Date: Thu, 6 Nov 2025 08:38:53 -0600 Subject: [PATCH 3/4] mock e2e test --- .github/workflows/e2e-tests.yml | 1 + .../claude-4-5-haiku-thread-checkpoint.json | 655 ++++++++++++++++++ 2 files changed, 656 insertions(+) create mode 100644 tests/system/responses/claude-4-5-haiku-thread-checkpoint.json diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 60189c1bd..be89d5f54 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -115,6 +115,7 @@ jobs: uses: ./.github/workflows/reusable-e2e-test.yml with: test-name: "thread-checkpoint" + mock: true secrets: inherit e2e-tests-complete: diff --git a/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json b/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json new file mode 100644 index 000000000..f87cb198c --- /dev/null +++ b/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json @@ -0,0 +1,655 @@ +{ + "version": "1.0", + "session": { + "id": 1, + "session_name": "claude-4-5-haiku-thread-checkpoint", + "created_at": "2025-11-06T14:35:11Z", + "description": "Proxy recording session" + }, + "interactions": [ + { + "request_id": "dabe4078-6851-43a6-8119-8da25ee324f9", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3017", + "Content-Type": "application/json", + "User-Agent": "Anthropic/Go 1.16.0", + "X-Api-Key": "dummy-api-key", + "X-Stainless-Arch": "arm64", + "X-Stainless-Lang": "go", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "1.16.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": "go1.25.2" + }, + "body": { + "max_tokens": 8192, + "messages": [ + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "Hello, this is the first message", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5-20251001", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are a helpful AI assistant. Provide brief responses. When you finish responding, call work_complete.", + "type": "text" + } + ], + "temperature": 1, + "tool_choice": { + "disable_parallel_tool_use": true, + "type": "auto" + }, + "tools": [ + { + "description": "Use this tool to indicate that you have completed the work requested by the user. This applies to any type of task: answering questions, completing coding tasks, performing research, running commands, or any other work.\n\n## When to Use This Tool\n\nUse this tool when you have:\n- **Answered a question**: You have a complete, final answer to the user's query\n- **Completed a coding task**: You have finished implementing, fixing, or modifying code as requested\n- **Finished a command or operation**: You have successfully executed the requested command or task\n- **Completed research or analysis**: You have gathered and synthesized the requested information\n- **Done the work**: Any other task the user requested is complete and verified\n\n## Guidelines\n\n- **Completeness**: Only use this tool when the work is FULLY complete and free of errors\n- **Verification**: Ensure you have verified the work is correct before using this tool\n- **Summary**: Provide a brief summary of what was accomplished (2-4 sentences max)\n - For questions: State the answer concisely\n - For coding: Summarize what code was changed/added\n - For commands: Note what was executed and the outcome\n - For research: Highlight key findings\n- **Brevity**: Keep summaries concise and to the point. No emojis unless user requested them.\n- **Finality**: All calls to this tool will end the conversation\n\n## What NOT to Do\n\n- Don't use this tool if you haven't completed the task yet\n- Don't use this tool if you need to gather more information\n- Don't use this tool if there are errors or issues remaining\n- Don't provide overly verbose summaries (keep it under 4 sentences)\n\n## Important\n\n**All calls to this tool will end the conversation.** Use only when you are certain the work is complete.\n\n## Examples\n\n**Question answering:**\n\"The capital of France is Paris, which has been the country's capital since 987 AD.\"\n\n**Coding task:**\n\"Implemented user authentication with JWT tokens. Added login endpoint, token validation middleware, and protected routes.\"\n\n**Command execution:**\n\"Successfully started the development server on port 3000. The application is now running and accessible.\"\n\n**Research:**\n\"React 19 introduces the new 'use' hook for data fetching and the compiler is now production-ready.\"", + "input_schema": { + "properties": { + "summary": { + "description": "A summary of the completed work, results, or final answer", + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "name": "work_complete" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", + "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", + "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:13Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99a550f3095b0f16-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Thu, 06 Nov 2025 14:35:14 GMT", + "Request-Id": "req_011CUroVtMwcT6xuSCwbtcik", + "Retry-After": "50", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "576", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-06T08:35:14.370302-06:00", + "sequence_number": 1, + "is_streaming": true, + "stream_chunks": [ + { + "chunk_index": 0, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01HTSo7Y8m4FAoEzSuSRxusb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 1, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 4 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello! Welcome\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"} }\n\n", + "time_delta": 25 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 8 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\ud83d\udc4b \\n\\nI'm here to help\"} }\n\n", + "time_delta": 40 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you with a\"} }\n\n", + "time_delta": 27 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" wide variety of tasks, including\"} }\n\n", + "time_delta": 55 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\":\"} }\n\n", + "time_delta": 17 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Answering questions\"} }\n\n", + "time_delta": 28 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Writing\"} }\n\n", + "time_delta": 29 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" an\"} }\n\n", + "time_delta": 38 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d debugging\"} }\n\n", + "time_delta": 15 + }, + { + "chunk_index": 14, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" code\\n- Providing\"} }\n\n", + "time_delta": 27 + }, + { + "chunk_index": 15, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" explanations an\"} }\n\n", + "time_delta": 27 + }, + { + "chunk_index": 16, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d research\"} }\n\n", + "time_delta": 18 + }, + { + "chunk_index": 17, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Helping\"} }\n\n", + "time_delta": 31 + }, + { + "chunk_index": 18, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with problem\"} }\n\n", + "time_delta": 60 + }, + { + "chunk_index": 19, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-solving\\n- An\"} }\n\n", + "time_delta": 88 + }, + { + "chunk_index": 20, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d much more!\\n\\nWhat would you like help\"} }\n\n", + "time_delta": 36 + }, + { + "chunk_index": 21, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with today?\"} }\n\n", + "time_delta": 30 + }, + { + "chunk_index": 22, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 30 + }, + { + "chunk_index": 23, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":70} }\n\n", + "time_delta": 49 + }, + { + "chunk_index": 24, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "74ea447c-6c18-4f87-b8fb-2b626de15f06", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3424", + "Content-Type": "application/json", + "User-Agent": "Anthropic/Go 1.16.0", + "X-Api-Key": "dummy-api-key", + "X-Stainless-Arch": "arm64", + "X-Stainless-Lang": "go", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "1.16.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": "go1.25.2" + }, + "body": { + "max_tokens": 8192, + "messages": [ + { + "content": [ + { + "text": "Hello, this is the first message", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "Hello! Welcome! \ud83d\udc4b \n\nI'm here to help you with a wide variety of tasks, including:\n- Answering questions\n- Writing and debugging code\n- Providing explanations and research\n- Helping with problem-solving\n- And much more!\n\nWhat would you like help with today?", + "type": "text" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "This is the second message", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5-20251001", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are a helpful AI assistant. Provide brief responses. When you finish responding, call work_complete.", + "type": "text" + } + ], + "temperature": 1, + "tool_choice": { + "disable_parallel_tool_use": true, + "type": "auto" + }, + "tools": [ + { + "description": "Use this tool to indicate that you have completed the work requested by the user. This applies to any type of task: answering questions, completing coding tasks, performing research, running commands, or any other work.\n\n## When to Use This Tool\n\nUse this tool when you have:\n- **Answered a question**: You have a complete, final answer to the user's query\n- **Completed a coding task**: You have finished implementing, fixing, or modifying code as requested\n- **Finished a command or operation**: You have successfully executed the requested command or task\n- **Completed research or analysis**: You have gathered and synthesized the requested information\n- **Done the work**: Any other task the user requested is complete and verified\n\n## Guidelines\n\n- **Completeness**: Only use this tool when the work is FULLY complete and free of errors\n- **Verification**: Ensure you have verified the work is correct before using this tool\n- **Summary**: Provide a brief summary of what was accomplished (2-4 sentences max)\n - For questions: State the answer concisely\n - For coding: Summarize what code was changed/added\n - For commands: Note what was executed and the outcome\n - For research: Highlight key findings\n- **Brevity**: Keep summaries concise and to the point. No emojis unless user requested them.\n- **Finality**: All calls to this tool will end the conversation\n\n## What NOT to Do\n\n- Don't use this tool if you haven't completed the task yet\n- Don't use this tool if you need to gather more information\n- Don't use this tool if there are errors or issues remaining\n- Don't provide overly verbose summaries (keep it under 4 sentences)\n\n## Important\n\n**All calls to this tool will end the conversation.** Use only when you are certain the work is complete.\n\n## Examples\n\n**Question answering:**\n\"The capital of France is Paris, which has been the country's capital since 987 AD.\"\n\n**Coding task:**\n\"Implemented user authentication with JWT tokens. Added login endpoint, token validation middleware, and protected routes.\"\n\n**Command execution:**\n\"Successfully started the development server on port 3000. The application is now running and accessible.\"\n\n**Research:**\n\"React 19 introduces the new 'use' hook for data fetching and the compiler is now production-ready.\"", + "input_schema": { + "properties": { + "summary": { + "description": "A summary of the completed work, results, or final answer", + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "name": "work_complete" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", + "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", + "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:15Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99a550ff7e110f16-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Thu, 06 Nov 2025 14:35:16 GMT", + "Request-Id": "req_011CUroW2rfqaVxEZifRtbNu", + "Retry-After": "46", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "797", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-06T08:35:16.578207-06:00", + "sequence_number": 2, + "is_streaming": true, + "stream_chunks": [ + { + "chunk_index": 0, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Mu31FJXzmdAeYd7ubyA82w\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1046,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\n", + "time_delta": 9 + }, + { + "chunk_index": 1, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m\"} }\n\n", + "time_delta": 21 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 75 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ready to help! What\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" would you like to \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"do or\"}}\n\n", + "time_delta": 7 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ask\"} }\n\n", + "time_delta": 50 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" about\"} }\n\n", + "time_delta": 22 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"?\"} }\n\n", + "time_delta": 31 + }, + { + "chunk_index": 11, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 33 + }, + { + "chunk_index": 12, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1046,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 13, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "af314b95-67b6-4a3b-ad3b-7648a0a6f94b", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3620", + "Content-Type": "application/json", + "User-Agent": "Anthropic/Go 1.16.0", + "X-Api-Key": "dummy-api-key", + "X-Stainless-Arch": "arm64", + "X-Stainless-Lang": "go", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "1.16.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": "go1.25.2" + }, + "body": { + "max_tokens": 8192, + "messages": [ + { + "content": [ + { + "text": "Hello, this is the first message", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "Hello! Welcome! \ud83d\udc4b \n\nI'm here to help you with a wide variety of tasks, including:\n- Answering questions\n- Writing and debugging code\n- Providing explanations and research\n- Helping with problem-solving\n- And much more!\n\nWhat would you like help with today?", + "type": "text" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "text": "This is the second message", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "I'm ready to help! What would you like to do or ask about?", + "type": "text" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "This is the third message", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5-20251001", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are a helpful AI assistant. Provide brief responses. When you finish responding, call work_complete.", + "type": "text" + } + ], + "temperature": 1, + "tool_choice": { + "disable_parallel_tool_use": true, + "type": "auto" + }, + "tools": [ + { + "description": "Use this tool to indicate that you have completed the work requested by the user. This applies to any type of task: answering questions, completing coding tasks, performing research, running commands, or any other work.\n\n## When to Use This Tool\n\nUse this tool when you have:\n- **Answered a question**: You have a complete, final answer to the user's query\n- **Completed a coding task**: You have finished implementing, fixing, or modifying code as requested\n- **Finished a command or operation**: You have successfully executed the requested command or task\n- **Completed research or analysis**: You have gathered and synthesized the requested information\n- **Done the work**: Any other task the user requested is complete and verified\n\n## Guidelines\n\n- **Completeness**: Only use this tool when the work is FULLY complete and free of errors\n- **Verification**: Ensure you have verified the work is correct before using this tool\n- **Summary**: Provide a brief summary of what was accomplished (2-4 sentences max)\n - For questions: State the answer concisely\n - For coding: Summarize what code was changed/added\n - For commands: Note what was executed and the outcome\n - For research: Highlight key findings\n- **Brevity**: Keep summaries concise and to the point. No emojis unless user requested them.\n- **Finality**: All calls to this tool will end the conversation\n\n## What NOT to Do\n\n- Don't use this tool if you haven't completed the task yet\n- Don't use this tool if you need to gather more information\n- Don't use this tool if there are errors or issues remaining\n- Don't provide overly verbose summaries (keep it under 4 sentences)\n\n## Important\n\n**All calls to this tool will end the conversation.** Use only when you are certain the work is complete.\n\n## Examples\n\n**Question answering:**\n\"The capital of France is Paris, which has been the country's capital since 987 AD.\"\n\n**Coding task:**\n\"Implemented user authentication with JWT tokens. Added login endpoint, token validation middleware, and protected routes.\"\n\n**Command execution:**\n\"Successfully started the development server on port 3000. The application is now running and accessible.\"\n\n**Research:**\n\"React 19 introduces the new 'use' hook for data fetching and the compiler is now production-ready.\"", + "input_schema": { + "properties": { + "summary": { + "description": "A summary of the completed work, results, or final answer", + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "name": "work_complete" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", + "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", + "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:17Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99a5510c3b920f16-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Thu, 06 Nov 2025 14:35:18 GMT", + "Request-Id": "req_011CUroWBc2RfvfqyaK7VMVU", + "Retry-After": "44", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "688", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-06T08:35:18.516794-06:00", + "sequence_number": 3, + "is_streaming": true, + "stream_chunks": [ + { + "chunk_index": 0, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01CJvMJ3GDCy2tKD93vLp9F7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1073,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\n", + "time_delta": 4 + }, + { + "chunk_index": 1, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m here whenever\"} }\n\n", + "time_delta": 49 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 60 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you need assistance\"}}\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\". Feel\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" free to ask me anything or\"} }\n\n", + "time_delta": 24 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" let me know what task\"}}\n\n", + "time_delta": 63 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you'd like help with!\"} }\n\n", + "time_delta": 50 + }, + { + "chunk_index": 10, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 3 + }, + { + "chunk_index": 11, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1073,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":29} }\n\n", + "time_delta": 87 + }, + { + "chunk_index": 12, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + } + ] +} \ No newline at end of file From de9baad32f758da1b529f100e4d4f570dde664aa Mon Sep 17 00:00:00 2001 From: ethanoroshiba Date: Thu, 6 Nov 2025 15:01:39 -0600 Subject: [PATCH 4/4] fix restoration, cli navigation --- policies/resources/thread_message.yaml | 2 +- .../api/thread/v1alpha1/thread_service.proto | 24 +- .../api/thread/v1alpha1/thread_types.proto | 20 + tests/system/framework/api_client.py | 10 +- .../claude-4-5-haiku-thread-checkpoint.json | 741 ++++++++++++++---- tests/system/test_thread_checkpoint.py | 270 ++++++- tim-api/internal/mapper/thread.go | 13 +- .../internal/services/thread/checkpoint.go | 159 +--- tim-api/internal/services/thread/handlers.go | 4 + .../services/thread/message_handlers.go | 220 ++++-- .../services/thread_context/handlers.go | 6 +- tim-cli-v2/cmd/root.go | 27 + tim-cli-v2/internal/client/client.go | 37 +- tim-cli-v2/internal/tui/model.go | 710 +++++++++++++++-- tim-db/gen/db/checkpoint.sql.go | 42 + tim-db/queries/checkpoint.sql | 16 + tim-proto/gen/openapi.yaml | 49 +- .../api/thread/v1alpha1/thread_service.pb.go | 207 +++-- .../v1alpha1/thread_service.swagger.json | 51 +- .../api/thread/v1alpha1/thread_types.pb.go | 115 ++- .../thread_service.connect.go | 12 +- .../thread_context_service.swagger.json | 5 + 22 files changed, 2170 insertions(+), 570 deletions(-) diff --git a/policies/resources/thread_message.yaml b/policies/resources/thread_message.yaml index 7795facd8..0c551b10b 100644 --- a/policies/resources/thread_message.yaml +++ b/policies/resources/thread_message.yaml @@ -5,6 +5,6 @@ resourcePolicy: resource: "thread-message" importDerivedRoles: ["common"] rules: - - actions: [ "read", "list", "stream", "create-user-message" ] + - actions: [ "read", "list", "stream", "create-user-message", "update" ] effect: EFFECT_ALLOW derivedRoles: [ "resource_owner" ] diff --git a/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto b/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto index 33564d3da..83d234a59 100644 --- a/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto +++ b/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto @@ -83,7 +83,7 @@ service ThreadService { } // Edit a thread message (with checkpoint restoration support) - rpc EditThreadMessage(EditThreadMessageRequest) returns (LlmMessage) { + rpc EditThreadMessage(EditThreadMessageRequest) returns (EditThreadMessageResponse) { option (google.api.http) = { post: "/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}:edit" body: "*" @@ -325,10 +325,24 @@ message EditThreadMessageRequest { (buf.validate.field).string.max_len = 32768 ]; - // User confirmed checkpoint restoration (required if checkpoint exists with files) - // If not provided and checkpoint restoration is needed, an error will be returned - // with details about what will be restored. - bool confirm_restore = 3; + // Whether to restore files from checkpoint (if one exists). + // If not set: returns error when checkpoint exists (to prompt user). + // If true: restores files from checkpoint and returns file restoration data. + // If false: saves without restoring (new checkpoint replaces old one). + optional bool restore = 3; +} + +// EditThreadMessageResponse contains the edited message and any file restoration data +message EditThreadMessageResponse { + // The updated message + LlmMessage message = 1 [ + (buf.validate.field).required = true, + (google.api.field_behavior) = REQUIRED + ]; + + // Files to restore from checkpoint (if applicable) + // The client should restore these files before continuing + repeated FileRestoration file_restorations = 2; } // ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation diff --git a/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto b/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto index 20dfd0255..9f96676c0 100644 --- a/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto +++ b/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto @@ -137,6 +137,9 @@ message LlmMessage { // Token usage for this message (optional, set after LLM response) tim.api.llm_response.v1alpha1.TokenUsage token_usage = 10 [(google.api.field_behavior) = OUTPUT_ONLY]; + // Whether a checkpoint exists for this message + bool has_checkpoint = 11 [(google.api.field_behavior) = OUTPUT_ONLY]; + // Validation rules to ensure that the correct data is set based on the role type option (buf.validate.message).cel = { id: "llm-message.role.valid_data_for_user_role" @@ -235,6 +238,23 @@ message StreamErrorEvent { string error = 1; } +// FileRestoration contains information about a file to restore from a checkpoint. +// This is a data transfer object, not an API resource. +// buf:lint:ignore AEP_0004_RESOURCE_ANNOTATION +message FileRestoration { + // The absolute path to the file + string path = 1 [ + (buf.validate.field).required = true, + (google.api.field_behavior) = REQUIRED + ]; + + // The content to restore + bytes content = 2 [ + (buf.validate.field).required = true, + (google.api.field_behavior) = REQUIRED + ]; +} + // An actor who may participate in creating LLM messages enum LlmMessageRole { // Default unspecified diff --git a/tests/system/framework/api_client.py b/tests/system/framework/api_client.py index d57d4b16c..4f7dfd6b6 100644 --- a/tests/system/framework/api_client.py +++ b/tests/system/framework/api_client.py @@ -233,22 +233,24 @@ def submit_user_message(self, thread_path: str, text: str) -> dict[str, Any]: return response.json() def edit_thread_message( - self, message_path: str, content: str, confirm_restore: bool = False + self, message_path: str, content: str, restore: bool = False ) -> dict[str, Any]: """Edit a thread message. Args: message_path: Path to the message (e.g., "orgs/{org}/users/{user}/threads/{thread}/messages/{message}") content: New content for the message - confirm_restore: If true, confirms checkpoint restoration + restore: If true, restores files from checkpoint and returns file restoration data. + If false, saves without restoring (new checkpoint replaces old one). + If not set, returns error when checkpoint exists (to prompt user). Returns: - The updated message + The updated message with optional file_restorations field """ response = self.client.post( self._url(f"{message_path}:edit"), headers=self._default_headers, - json={"content": content, "confirm_restore": confirm_restore}, + json={"content": content, "restore": restore}, ) response.raise_for_status() return response.json() diff --git a/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json b/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json index f87cb198c..2fe5c95f9 100644 --- a/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json +++ b/tests/system/responses/claude-4-5-haiku-thread-checkpoint.json @@ -3,12 +3,12 @@ "session": { "id": 1, "session_name": "claude-4-5-haiku-thread-checkpoint", - "created_at": "2025-11-06T14:35:11Z", + "created_at": "2025-11-06T20:57:37Z", "description": "Proxy recording session" }, "interactions": [ { - "request_id": "dabe4078-6851-43a6-8119-8da25ee324f9", + "request_id": "04b11606-ce02-45d9-822a-1b0c50edf2ae", "protocol": "REST", "method": "POST", "endpoint": "/v1/messages", @@ -87,163 +87,103 @@ "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", - "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T20:57:39Z", "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", - "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T20:57:39Z", "Anthropic-Ratelimit-Requests-Limit": "4000", "Anthropic-Ratelimit-Requests-Remaining": "3999", - "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T20:57:39Z", "Anthropic-Ratelimit-Tokens-Limit": "4800000", "Anthropic-Ratelimit-Tokens-Remaining": "4799000", - "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:13Z", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T20:57:39Z", "Cache-Control": "no-cache", "Cf-Cache-Status": "DYNAMIC", - "Cf-Ray": "99a550f3095b0f16-DFW", + "Cf-Ray": "99a78125af122c87-DFW", "Content-Type": "text/event-stream; charset=utf-8", - "Date": "Thu, 06 Nov 2025 14:35:14 GMT", - "Request-Id": "req_011CUroVtMwcT6xuSCwbtcik", - "Retry-After": "50", + "Date": "Thu, 06 Nov 2025 20:57:40 GMT", + "Request-Id": "req_011CUsJfK95ou9bWAezDbHP6", + "Retry-After": "22", "Server": "cloudflare", "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", - "X-Envoy-Upstream-Service-Time": "576", + "X-Envoy-Upstream-Service-Time": "713", "X-Robots-Tag": "none" }, "body": null }, - "timestamp": "2025-11-06T08:35:14.370302-06:00", + "timestamp": "2025-11-06T14:57:40.242176-06:00", "sequence_number": 1, "is_streaming": true, "stream_chunks": [ { "chunk_index": 0, - "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01HTSo7Y8m4FAoEzSuSRxusb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}} }\n\n", + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KN1pyE3YMiAQeLmeJxNpu3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\"}} }\n\n", "time_delta": 0 }, { "chunk_index": 1, - "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", - "time_delta": 4 + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 0 }, { "chunk_index": 2, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello! Welcome\"} }\n\n", - "time_delta": 1 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello! I'm here to help you\"} }\n\n", + "time_delta": 0 }, { "chunk_index": 3, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"} }\n\n", - "time_delta": 25 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with any questions,\"} }\n\n", + "time_delta": 63 }, { "chunk_index": 4, "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", - "time_delta": 8 + "time_delta": 0 }, { "chunk_index": 5, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"} }\n\n", - "time_delta": 0 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" coding tasks, research\"} }\n\n", + "time_delta": 2 }, { "chunk_index": 6, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\ud83d\udc4b \\n\\nI'm here to help\"} }\n\n", - "time_delta": 40 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", or other\"} }\n\n", + "time_delta": 18 }, { "chunk_index": 7, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you with a\"} }\n\n", - "time_delta": 27 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" work you nee\"} }\n\n", + "time_delta": 50 }, { "chunk_index": 8, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" wide variety of tasks, including\"} }\n\n", - "time_delta": 55 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d assistance with. What would you like me\"} }\n\n", + "time_delta": 58 }, { "chunk_index": 9, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\":\"} }\n\n", - "time_delta": 17 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" to help you with today?\"} }\n\n", + "time_delta": 35 }, { "chunk_index": 10, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Answering questions\"} }\n\n", - "time_delta": 28 + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 74 }, { "chunk_index": 11, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Writing\"} }\n\n", - "time_delta": 29 + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":39} }\n\n", + "time_delta": 67 }, { "chunk_index": 12, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" an\"} }\n\n", - "time_delta": 38 - }, - { - "chunk_index": 13, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d debugging\"} }\n\n", - "time_delta": 15 - }, - { - "chunk_index": 14, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" code\\n- Providing\"} }\n\n", - "time_delta": 27 - }, - { - "chunk_index": 15, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" explanations an\"} }\n\n", - "time_delta": 27 - }, - { - "chunk_index": 16, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d research\"} }\n\n", - "time_delta": 18 - }, - { - "chunk_index": 17, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Helping\"} }\n\n", - "time_delta": 31 - }, - { - "chunk_index": 18, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with problem\"} }\n\n", - "time_delta": 60 - }, - { - "chunk_index": 19, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-solving\\n- An\"} }\n\n", - "time_delta": 88 - }, - { - "chunk_index": 20, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d much more!\\n\\nWhat would you like help\"} }\n\n", - "time_delta": 36 - }, - { - "chunk_index": 21, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with today?\"} }\n\n", - "time_delta": 30 - }, - { - "chunk_index": 22, - "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", - "time_delta": 30 - }, - { - "chunk_index": 23, - "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":70} }\n\n", - "time_delta": 49 - }, - { - "chunk_index": 24, - "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", "time_delta": 0 } ] }, { - "request_id": "74ea447c-6c18-4f87-b8fb-2b626de15f06", + "request_id": "bce01a24-039d-4a08-b39e-6590c7a490c0", "protocol": "REST", "method": "POST", "endpoint": "/v1/messages", @@ -252,7 +192,7 @@ "Accept": "application/json", "Accept-Encoding": "gzip", "Anthropic-Version": "2023-06-01", - "Content-Length": "3424", + "Content-Length": "3314", "Content-Type": "application/json", "User-Agent": "Anthropic/Go 1.16.0", "X-Api-Key": "dummy-api-key", @@ -279,7 +219,7 @@ { "content": [ { - "text": "Hello! Welcome! \ud83d\udc4b \n\nI'm here to help you with a wide variety of tasks, including:\n- Answering questions\n- Writing and debugging code\n- Providing explanations and research\n- Helping with problem-solving\n- And much more!\n\nWhat would you like help with today?", + "text": "Hello! I'm here to help you with any questions, coding tasks, research, or other work you need assistance with. What would you like me to help you with today?", "type": "text" } ], @@ -340,108 +280,103 @@ "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", - "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T20:57:43Z", "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", - "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T20:57:43Z", "Anthropic-Ratelimit-Requests-Limit": "4000", "Anthropic-Ratelimit-Requests-Remaining": "3999", - "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T20:57:43Z", "Anthropic-Ratelimit-Tokens-Limit": "4800000", "Anthropic-Ratelimit-Tokens-Remaining": "4799000", - "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:15Z", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T20:57:43Z", "Cache-Control": "no-cache", "Cf-Cache-Status": "DYNAMIC", - "Cf-Ray": "99a550ff7e110f16-DFW", + "Cf-Ray": "99a7813edf572c87-DFW", "Content-Type": "text/event-stream; charset=utf-8", - "Date": "Thu, 06 Nov 2025 14:35:16 GMT", - "Request-Id": "req_011CUroW2rfqaVxEZifRtbNu", - "Retry-After": "46", + "Date": "Thu, 06 Nov 2025 20:57:44 GMT", + "Request-Id": "req_011CUsJfcSPpNbp56pZyn3uM", + "Retry-After": "18", "Server": "cloudflare", "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", - "X-Envoy-Upstream-Service-Time": "797", + "X-Envoy-Upstream-Service-Time": "912", "X-Robots-Tag": "none" }, "body": null }, - "timestamp": "2025-11-06T08:35:16.578207-06:00", + "timestamp": "2025-11-06T14:57:44.568525-06:00", "sequence_number": 2, "is_streaming": true, "stream_chunks": [ { "chunk_index": 0, - "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Mu31FJXzmdAeYd7ubyA82w\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1046,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\n", - "time_delta": 9 + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Ru8m765xiJ3N65vW7qkvF2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1015,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\n", + "time_delta": 0 }, { "chunk_index": 1, - "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", "time_delta": 0 }, { "chunk_index": 2, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", "time_delta": 0 }, { "chunk_index": 3, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m\"} }\n\n", - "time_delta": 21 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m ready to help! Please let\"} }\n\n", + "time_delta": 5 }, { "chunk_index": 4, "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", - "time_delta": 75 + "time_delta": 27 }, { "chunk_index": 5, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ready to help! What\"} }\n\n", + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" me know what you'd like me\"} }\n\n", "time_delta": 0 }, { "chunk_index": 6, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" would you like to \"} }\n\n", - "time_delta": 0 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" to do. Whether it's answering a\"}}\n\n", + "time_delta": 82 }, { "chunk_index": 7, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"do or\"}}\n\n", - "time_delta": 7 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" question, writing code, providing\"}}\n\n", + "time_delta": 53 }, { "chunk_index": 8, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ask\"} }\n\n", - "time_delta": 50 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" information, or something else, just share\"} }\n\n", + "time_delta": 105 }, { "chunk_index": 9, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" about\"} }\n\n", - "time_delta": 22 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the details and I'll get started.\"} }\n\n", + "time_delta": 54 }, { "chunk_index": 10, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"?\"} }\n\n", - "time_delta": 31 + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 66 }, { "chunk_index": 11, - "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", - "time_delta": 33 + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1015,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":49}}\n\n", + "time_delta": 56 }, { "chunk_index": 12, - "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1046,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19} }\n\n", - "time_delta": 23 - }, - { - "chunk_index": 13, - "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "data": "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", "time_delta": 0 } ] }, { - "request_id": "af314b95-67b6-4a3b-ad3b-7648a0a6f94b", + "request_id": "fbe5bfcd-3f34-4b98-8465-4882e56f32f1", "protocol": "REST", "method": "POST", "endpoint": "/v1/messages", @@ -450,7 +385,7 @@ "Accept": "application/json", "Accept-Encoding": "gzip", "Anthropic-Version": "2023-06-01", - "Content-Length": "3620", + "Content-Length": "3651", "Content-Type": "application/json", "User-Agent": "Anthropic/Go 1.16.0", "X-Api-Key": "dummy-api-key", @@ -477,7 +412,7 @@ { "content": [ { - "text": "Hello! Welcome! \ud83d\udc4b \n\nI'm here to help you with a wide variety of tasks, including:\n- Answering questions\n- Writing and debugging code\n- Providing explanations and research\n- Helping with problem-solving\n- And much more!\n\nWhat would you like help with today?", + "text": "Hello! I'm here to help you with any questions, coding tasks, research, or other work you need assistance with. What would you like me to help you with today?", "type": "text" } ], @@ -495,7 +430,7 @@ { "content": [ { - "text": "I'm ready to help! What would you like to do or ask about?", + "text": "I'm ready to help! Please let me know what you'd like me to do. Whether it's answering a question, writing code, providing information, or something else, just share the details and I'll get started.", "type": "text" } ], @@ -556,97 +491,567 @@ "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", - "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T20:57:47Z", "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", - "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T20:57:47Z", "Anthropic-Ratelimit-Requests-Limit": "4000", "Anthropic-Ratelimit-Requests-Remaining": "3999", - "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T20:57:47Z", "Anthropic-Ratelimit-Tokens-Limit": "4800000", "Anthropic-Ratelimit-Tokens-Remaining": "4799000", - "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T14:35:17Z", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T20:57:47Z", "Cache-Control": "no-cache", "Cf-Cache-Status": "DYNAMIC", - "Cf-Ray": "99a5510c3b920f16-DFW", + "Cf-Ray": "99a781581de12c87-DFW", "Content-Type": "text/event-stream; charset=utf-8", - "Date": "Thu, 06 Nov 2025 14:35:18 GMT", - "Request-Id": "req_011CUroWBc2RfvfqyaK7VMVU", - "Retry-After": "44", + "Date": "Thu, 06 Nov 2025 20:57:48 GMT", + "Request-Id": "req_011CUsJfudVqwHPAegrTjQvi", + "Retry-After": "13", "Server": "cloudflare", "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", - "X-Envoy-Upstream-Service-Time": "688", + "X-Envoy-Upstream-Service-Time": "700", "X-Robots-Tag": "none" }, "body": null }, - "timestamp": "2025-11-06T08:35:18.516794-06:00", + "timestamp": "2025-11-06T14:57:48.355855-06:00", "sequence_number": 3, "is_streaming": true, "stream_chunks": [ { "chunk_index": 0, - "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01CJvMJ3GDCy2tKD93vLp9F7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1073,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\n", - "time_delta": 4 + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01PE6H7xLTnDemwAnBZFy7Ca\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1072,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\n", + "time_delta": 0 }, { "chunk_index": 1, - "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", "time_delta": 0 }, { "chunk_index": 2, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I\"} }\n\n", "time_delta": 0 }, { "chunk_index": 3, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m here whenever\"} }\n\n", - "time_delta": 49 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'m here\"} }\n\n", + "time_delta": 20 }, { "chunk_index": 4, "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", - "time_delta": 60 + "time_delta": 0 }, { "chunk_index": 5, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you need assistance\"}}\n\n", + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" an\"} }\n\n", "time_delta": 0 }, { "chunk_index": 6, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\". Feel\"} }\n\n", - "time_delta": 0 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d ready to assist whenever\"} }\n\n", + "time_delta": 5 }, { "chunk_index": 7, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" free to ask me anything or\"} }\n\n", - "time_delta": 24 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you need!\"} }\n\n", + "time_delta": 27 }, { "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Feel\"} }\n\n", + "time_delta": 35 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" free to ask\"} }\n\n", + "time_delta": 39 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" me anything or\"} }\n\n", + "time_delta": 108 + }, + { + "chunk_index": 11, "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" let me know what task\"}}\n\n", - "time_delta": 63 + "time_delta": 26 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you'd like help with. What can\"} }\n\n", + "time_delta": 27 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" I do for you?\"} }\n\n", + "time_delta": 52 + }, + { + "chunk_index": 14, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 51 + }, + { + "chunk_index": 15, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1072,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":39} }\n\n", + "time_delta": 61 + }, + { + "chunk_index": 16, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "30107242-133c-4e9e-973f-4107ef375c63", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3017", + "Content-Type": "application/json", + "User-Agent": "Anthropic/Go 1.16.0", + "X-Api-Key": "dummy-api-key", + "X-Stainless-Arch": "arm64", + "X-Stainless-Lang": "go", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "1.16.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": "go1.25.2" + }, + "body": { + "max_tokens": 8192, + "messages": [ + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "This is the EDITED first message", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5-20251001", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are a helpful AI assistant. Provide brief responses. When you finish responding, call work_complete.", + "type": "text" + } + ], + "temperature": 1, + "tool_choice": { + "disable_parallel_tool_use": true, + "type": "auto" + }, + "tools": [ + { + "description": "Use this tool to indicate that you have completed the work requested by the user. This applies to any type of task: answering questions, completing coding tasks, performing research, running commands, or any other work.\n\n## When to Use This Tool\n\nUse this tool when you have:\n- **Answered a question**: You have a complete, final answer to the user's query\n- **Completed a coding task**: You have finished implementing, fixing, or modifying code as requested\n- **Finished a command or operation**: You have successfully executed the requested command or task\n- **Completed research or analysis**: You have gathered and synthesized the requested information\n- **Done the work**: Any other task the user requested is complete and verified\n\n## Guidelines\n\n- **Completeness**: Only use this tool when the work is FULLY complete and free of errors\n- **Verification**: Ensure you have verified the work is correct before using this tool\n- **Summary**: Provide a brief summary of what was accomplished (2-4 sentences max)\n - For questions: State the answer concisely\n - For coding: Summarize what code was changed/added\n - For commands: Note what was executed and the outcome\n - For research: Highlight key findings\n- **Brevity**: Keep summaries concise and to the point. No emojis unless user requested them.\n- **Finality**: All calls to this tool will end the conversation\n\n## What NOT to Do\n\n- Don't use this tool if you haven't completed the task yet\n- Don't use this tool if you need to gather more information\n- Don't use this tool if there are errors or issues remaining\n- Don't provide overly verbose summaries (keep it under 4 sentences)\n\n## Important\n\n**All calls to this tool will end the conversation.** Use only when you are certain the work is complete.\n\n## Examples\n\n**Question answering:**\n\"The capital of France is Paris, which has been the country's capital since 987 AD.\"\n\n**Coding task:**\n\"Implemented user authentication with JWT tokens. Added login endpoint, token validation middleware, and protected routes.\"\n\n**Command execution:**\n\"Successfully started the development server on port 3000. The application is now running and accessible.\"\n\n**Research:**\n\"React 19 introduces the new 'use' hook for data fetching and the compiler is now production-ready.\"", + "input_schema": { + "properties": { + "summary": { + "description": "A summary of the completed work, results, or final answer", + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "name": "work_complete" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", + "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", + "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T20:57:51Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99a781714b9d2c87-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Thu, 06 Nov 2025 20:57:52 GMT", + "Request-Id": "req_011CUsJgCqbVjZMqNTeFoRBY", + "Retry-After": "9", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "727", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-06T14:57:52.360495-06:00", + "sequence_number": 4, + "is_streaming": true, + "stream_chunks": [ + { + "chunk_index": 0, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_014ekBT63o6dXgPPtpqsUGiF\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 1, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'm\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ready to help!\"} }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 45 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" However, I don\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'t see a specific question or task in\"} }\n\n", + "time_delta": 116 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" your message. Could you please let\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" me know what you'd like me to\"} }\n\n", + "time_delta": 0 }, { "chunk_index": 9, - "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you'd like help with!\"} }\n\n", - "time_delta": 50 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" do? For\"} }\n\n", + "time_delta": 64 }, { "chunk_index": 10, - "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", - "time_delta": 3 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" example:\\n\\n- Answer a question\"} }\n\n", + "time_delta": 105 }, { "chunk_index": 11, - "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1073,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":29} }\n\n", - "time_delta": 87 + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Help with coding\\n- Provide\"} }\n\n", + "time_delta": 44 }, { "chunk_index": 12, - "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" information on a topic\\n- Complete a specific\"} }\n\n", + "time_delta": 66 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" task\\n\\nPlease share the\"} }\n\n", + "time_delta": 85 + }, + { + "chunk_index": 14, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" details,\"} }\n\n", + "time_delta": 95 + }, + { + "chunk_index": 15, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" and I'll be\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 16, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" happy to assist!\"} }\n\n", + "time_delta": 53 + }, + { + "chunk_index": 17, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 18, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":79}}\n\n", + "time_delta": 21 + }, + { + "chunk_index": 19, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "b54d98d3-88ce-43e5-8269-ba16519ceb6b", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3017", + "Content-Type": "application/json", + "User-Agent": "Anthropic/Go 1.16.0", + "X-Api-Key": "dummy-api-key", + "X-Stainless-Arch": "arm64", + "X-Stainless-Lang": "go", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "1.16.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": "go1.25.2" + }, + "body": { + "max_tokens": 8192, + "messages": [ + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "This is the EDITED first message", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5-20251001", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are a helpful AI assistant. Provide brief responses. When you finish responding, call work_complete.", + "type": "text" + } + ], + "temperature": 1, + "tool_choice": { + "disable_parallel_tool_use": true, + "type": "auto" + }, + "tools": [ + { + "description": "Use this tool to indicate that you have completed the work requested by the user. This applies to any type of task: answering questions, completing coding tasks, performing research, running commands, or any other work.\n\n## When to Use This Tool\n\nUse this tool when you have:\n- **Answered a question**: You have a complete, final answer to the user's query\n- **Completed a coding task**: You have finished implementing, fixing, or modifying code as requested\n- **Finished a command or operation**: You have successfully executed the requested command or task\n- **Completed research or analysis**: You have gathered and synthesized the requested information\n- **Done the work**: Any other task the user requested is complete and verified\n\n## Guidelines\n\n- **Completeness**: Only use this tool when the work is FULLY complete and free of errors\n- **Verification**: Ensure you have verified the work is correct before using this tool\n- **Summary**: Provide a brief summary of what was accomplished (2-4 sentences max)\n - For questions: State the answer concisely\n - For coding: Summarize what code was changed/added\n - For commands: Note what was executed and the outcome\n - For research: Highlight key findings\n- **Brevity**: Keep summaries concise and to the point. No emojis unless user requested them.\n- **Finality**: All calls to this tool will end the conversation\n\n## What NOT to Do\n\n- Don't use this tool if you haven't completed the task yet\n- Don't use this tool if you need to gather more information\n- Don't use this tool if there are errors or issues remaining\n- Don't provide overly verbose summaries (keep it under 4 sentences)\n\n## Important\n\n**All calls to this tool will end the conversation.** Use only when you are certain the work is complete.\n\n## Examples\n\n**Question answering:**\n\"The capital of France is Paris, which has been the country's capital since 987 AD.\"\n\n**Coding task:**\n\"Implemented user authentication with JWT tokens. Added login endpoint, token validation middleware, and protected routes.\"\n\n**Command execution:**\n\"Successfully started the development server on port 3000. The application is now running and accessible.\"\n\n**Research:**\n\"React 19 introduces the new 'use' hook for data fetching and the compiler is now production-ready.\"", + "input_schema": { + "properties": { + "summary": { + "description": "A summary of the completed work, results, or final answer", + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "name": "work_complete" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Anthropic-Organization-Id": "0bb082eb-641d-4255-b857-33d562480ec2", + "Anthropic-Ratelimit-Input-Tokens-Limit": "4000000", + "Anthropic-Ratelimit-Input-Tokens-Remaining": "3999000", + "Anthropic-Ratelimit-Input-Tokens-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-06T20:57:51Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-06T20:57:51Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99a781716bd02c87-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Thu, 06 Nov 2025 20:57:52 GMT", + "Request-Id": "req_011CUsJgD6Uo7dGaNEaR2CNH", + "Retry-After": "9", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "785", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-06T14:57:52.485176-06:00", + "sequence_number": 5, + "is_streaming": true, + "stream_chunks": [ + { + "chunk_index": 0, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01LucwLf8cdJAhraewL3oLqm\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4,\"service_tier\":\"standard\"}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 1, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I understand you've\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" sent an edited first message.\"} }\n\n", + "time_delta": 13 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 56 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" However, I don't see\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a specific question or task for me to help\"} }\n\n", + "time_delta": 77 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with. \\n\\nCould you please clarify what you\"} }\n\n", + "time_delta": 76 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'d like me to \"} }\n\n", + "time_delta": 10 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"do? For example,\"} }\n\n", + "time_delta": 54 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" are you:\"} }\n\n", + "time_delta": 20 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Asking a\"}}\n\n", + "time_delta": 27 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" question?\\n- Requesting\"} }\n\n", + "time_delta": 103 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" help with coding\"}}\n\n", + "time_delta": 3 + }, + { + "chunk_index": 14, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" or a\"} }\n\n", + "time_delta": 52 + }, + { + "chunk_index": 15, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" project?\\n- Looking\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 16, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" for information on\"} }\n\n", + "time_delta": 32 + }, + { + "chunk_index": 17, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a specific topic?\\n- Nee\"}}\n\n", + "time_delta": 38 + }, + { + "chunk_index": 18, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d me to complete\"} }\n\n", + "time_delta": 113 + }, + { + "chunk_index": 19, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a task?\\n\\nPlease\"} }\n\n", + "time_delta": 24 + }, + { + "chunk_index": 20, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" provide more\"} }\n\n", + "time_delta": 34 + }, + { + "chunk_index": 21, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" details so\"} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 22, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" I can assist you effectively.\"} }\n\n", + "time_delta": 32 + }, + { + "chunk_index": 23, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 28 + }, + { + "chunk_index": 24, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":100} }\n\n", + "time_delta": 58 + }, + { + "chunk_index": 25, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", "time_delta": 0 } ] diff --git a/tests/system/test_thread_checkpoint.py b/tests/system/test_thread_checkpoint.py index 67f1be67b..e48c614d6 100644 --- a/tests/system/test_thread_checkpoint.py +++ b/tests/system/test_thread_checkpoint.py @@ -8,12 +8,19 @@ 5. Edit a message to trigger checkpoint restoration 6. Verify restoration requires confirmation 7. Verify restoration deletes subsequent messages + 8. Verify LLM relay job is kicked off after edit + 9. Verify thread begins processing and streams new responses + 10. Verify thread returns to IDLE after processing Checkpoint Feature Overview: - Checkpoints capture the state of a working directory after message completion -- When editing a message, the system can restore files to their state at that checkpoint +- When editing a message, the API returns files to restore to their checkpoint state +- The API returns base64-encoded file contents for restoration +- The client is responsible for applying the restorations (and optionally deleting extra files) - Restoration requires explicit confirmation if files will be modified - All messages after the edited message are deleted +- After restoration, a new LLM relay job is automatically kicked off +- The LLM processes the edited message and streams a new response Running this test: # From the workspace root @@ -27,6 +34,7 @@ uv run pytest -v -s test_thread_checkpoint.py """ +import base64 import tempfile import threading import time @@ -101,13 +109,18 @@ def test_thread_checkpoint_basic( This test verifies: 1. Checkpoint creation after message completion (when working directory is configured) 2. File state tracking across multiple checkpoints - 3. Edit-without-confirmation fails with FailedPrecondition error - 4. Edit-with-confirmation succeeds and triggers restoration - 5. Subsequent messages are deleted after restoration - 6. File restoration uses file_write tool calls + 3. Edit with restore=False fails when checkpoint exists + 4. Edit with restore=True succeeds and returns file_restorations in the response + 5. Client applies file_restorations to restore files to checkpoint state + 6. Subsequent messages are deleted after restoration + 7. LLM relay job is kicked off after edit (thread state changes to PROCESSING) + 8. Thread begins processing and streams new responses + 9. Thread eventually returns to IDLE after processing + 10. Files are correctly restored to their state at the edited message checkpoint The test creates multiple messages, modifies files between messages, then edits - an earlier message to verify checkpoint restoration behavior. + an earlier message to verify checkpoint restoration behavior and that the LLM + generates a new response with proper streaming. """ test_summary.org = test_org test_summary.user = test_user @@ -197,6 +210,14 @@ def test_thread_checkpoint_basic( if not collector.has_idle_state(): raise AssertionError(f"Thread did not become IDLE within {max_wait} seconds") + + # Race condition fix: Stream events arrive before DB transaction commits + # Wait for database to finish committing, then verify via API + time.sleep(2) + thread_state = api_client.get_thread(thread.path) + if thread_state.llm_state != "THREAD_LLM_STATE_IDLE": + raise AssertionError(f"Thread state is {thread_state.llm_state}, expected IDLE") + print(" ✓ Initial message completed (checkpoint 1 should be created)") # Get initial messages to track message UIDs @@ -228,6 +249,9 @@ def test_thread_checkpoint_basic( if collector.count_idle_states() < 2: raise AssertionError("Thread did not return to IDLE after second message") + + # Wait for DB transaction to commit + time.sleep(2) print(" ✓ Second message completed (checkpoint 2 should be created)") # Modify files to create different state @@ -251,6 +275,9 @@ def test_thread_checkpoint_basic( if collector.count_idle_states() < 3: raise AssertionError("Thread did not return to IDLE after third message") + + # Wait for DB transaction to commit + time.sleep(2) print(" ✓ Third message completed (checkpoint 3 should be created)") # Get message count before edit @@ -258,21 +285,21 @@ def test_thread_checkpoint_basic( message_count_before = len(messages_before_edit.results) print(f"\nMessages before edit: {message_count_before}") - # Test 1: Try to edit first message WITHOUT confirm_restore + # Test 1: Try to edit first message WITHOUT restore parameter # This should fail with FailedPrecondition if checkpoint exists - print("\nTest 1: Attempting to edit without confirmation...") + print("\nTest 1: Attempting to edit without restore parameter...") try: api_client.edit_thread_message( first_message_path, "This is the EDITED first message", - confirm_restore=False, + restore=False, ) # If we get here, either no checkpoint exists or the API isn't working - print(" ⚠ Edit succeeded without confirmation (no checkpoint or API issue)") + print(" ⚠ Edit succeeded without restore (no checkpoint or API issue)") except httpx.HTTPStatusError as e: # Expecting FAILED_PRECONDITION (HTTP 400 or similar) if e.response.status_code in [400, 412]: # FailedPrecondition - print(" ✓ Edit rejected without confirmation (as expected)") + print(" ✓ Edit rejected without restore (as expected)") error_body = e.response.text print(f" Error message: {error_body[:200]}...") # Verify error message mentions checkpoint restoration @@ -282,22 +309,110 @@ def test_thread_checkpoint_basic( else: raise - # Test 2: Edit first message WITH confirm_restore - print("\nTest 2: Editing message with confirmation...") + # Test 2: Edit first message WITH restore=True + print("\nTest 2: Editing message with restore=True...") + + # Reset event collector to track new events after edit + collector.events.clear() + collector.event_counts.clear() + print(" Reset event collector to track post-edit events") + try: - edited_message = api_client.edit_thread_message( + edited_response = api_client.edit_thread_message( first_message_path, "This is the EDITED first message", - confirm_restore=True, + restore=True, ) print(" ✓ Message edited successfully") + edited_message = edited_response.get("message", {}) + # Connect-RPC uses camelCase for JSON field names + file_restorations = edited_response.get("fileRestorations", []) print(f" Edited message UID: {edited_message.get('uid', '')}") + print(f" File restorations returned: {len(file_restorations)}") + + # Apply file restorations from the API response + if file_restorations: + print(f"\nApplying {len(file_restorations)} file restorations...") + for restoration in file_restorations: + file_path = restoration["path"] + # Content is base64-encoded bytes in JSON, decode it + content_bytes = base64.b64decode(restoration["content"]) + Path(file_path).parent.mkdir(parents=True, exist_ok=True) + Path(file_path).write_bytes(content_bytes) + print(f" Restored: {file_path} ({len(content_bytes)} bytes)") + print(" ✓ All file restorations applied") + else: + print(" ⚠ No file restorations returned from API") except httpx.HTTPStatusError as e: print(f" ✗ Edit failed: {e.response.status_code} - {e.response.text[:200]}") raise - # Wait a moment for processing + # Verify LLM relay job is kicked off by checking for PROCESSING state + print("\nVerifying LLM relay job kicked off...") + start_time = time.time() + processing_detected = False + while time.time() - start_time < 10: + events = collector.get_events() + for event in events: + if event.thread_state_change: + state = event.thread_state_change.llm_state + if state == "THREAD_LLM_STATE_PROCESSING": + processing_detected = True + print(" ✓ Thread state changed to PROCESSING (LLM relay job started)") + break + if processing_detected: + break + time.sleep(0.5) + + if not processing_detected: + print(" ⚠ PROCESSING state not detected in stream events") + + # Wait for LLM to complete processing and return to IDLE + print("\nWaiting for LLM to complete processing after edit...") + start_time = time.time() + llm_completed = False + while time.time() - start_time < max_wait: + events = collector.get_events() + for event in reversed(events): + if event.thread_state_change: + state = event.thread_state_change.llm_state + if state == "THREAD_LLM_STATE_IDLE": + llm_completed = True + break + if llm_completed: + break + time.sleep(1) + + if not llm_completed: + raise AssertionError( + f"Thread did not return to IDLE within {max_wait} seconds after edit" + ) + + # Wait for DB transaction to commit time.sleep(2) + print(" ✓ Thread returned to IDLE (LLM processing complete)") + + # Verify streaming responses were received + print("\nVerifying streaming responses after edit...") + post_edit_content_start = collector.get_event_count("ContentStart") + post_edit_content_delta = collector.get_event_count("ContentDelta") + post_edit_content_stop = collector.get_event_count("ContentStop") + + print(f" ContentStart events: {post_edit_content_start}") + print(f" ContentDelta events: {post_edit_content_delta}") + print(f" ContentStop events: {post_edit_content_stop}") + + # Verify we got streaming events (LLM generated a response) + assert post_edit_content_start > 0, ( + "No ContentStart events after edit - LLM may not have responded" + ) + assert post_edit_content_delta > 0, ( + "No ContentDelta events after edit - LLM may not have streamed response" + ) + assert post_edit_content_stop > 0, ( + "No ContentStop events after edit - LLM response may not have completed" + ) + print(" ✓ LLM generated and streamed new response after edit") # Verify messages after edit print("\nVerifying messages after edit...") @@ -307,7 +422,7 @@ def test_thread_checkpoint_basic( print(f" Messages after edit: {message_count_after}") # Should have fewer messages (message 2, message 3, and their responses deleted) - # We expect: edited message 1, assistant response 1 (with file_write tool calls for restoration) + # We expect: edited message 1, new assistant response to the edited message assert message_count_after < message_count_before, ( f"Message count should decrease after edit (before: {message_count_before}, after: {message_count_after})" ) @@ -326,25 +441,89 @@ def test_thread_checkpoint_basic( else: print(" ⚠ Could not find edited message in results") - # Check for file_write tool calls in the restoration message - print("\nChecking for file restoration tool calls...") - has_file_writes = False - for msg in messages_after_edit.results: - if msg.role.value == "LLM_MESSAGE_ROLE_ASSISTANT": - for content in msg.contents: - if content.tool_call and content.tool_call.name == "file_write": - has_file_writes = True - print(f" Found file_write tool call: {content.tool_call.path}") - - if has_file_writes: - print(" ✓ Checkpoint restoration tool calls found") + # Note: File restoration is handled by the CLIENT via the API response, + # not by the LLM via tool calls. The file_restorations field in the + # EditThreadMessage response contains the files to restore. + + # CRITICAL: Verify files are actually restored to checkpoint 1 state + print("\nVerifying file restoration (checking actual file contents)...") + files_restored_correctly = True + file_check_results = [] + + # Check test1.txt - should be restored to initial content + test1_path = working_dir / "test1.txt" + if test1_path.exists(): + actual_content = test1_path.read_text() + expected_content = initial_files["test1.txt"] + if actual_content == expected_content: + print(" ✓ test1.txt restored correctly") + file_check_results.append(("test1.txt", True, "restored")) + else: + print(" ✗ test1.txt NOT restored correctly") + print(f" Expected: {repr(expected_content[:50])}") + print(f" Actual: {repr(actual_content[:50])}") + files_restored_correctly = False + file_check_results.append(("test1.txt", False, "content mismatch")) + else: + print(" ✗ test1.txt does not exist") + files_restored_correctly = False + file_check_results.append(("test1.txt", False, "missing")) + + # Check test2.txt - should be restored to initial content + test2_path = working_dir / "test2.txt" + if test2_path.exists(): + actual_content = test2_path.read_text() + expected_content = initial_files["test2.txt"] + if actual_content == expected_content: + print(" ✓ test2.txt restored correctly") + file_check_results.append(("test2.txt", True, "restored")) + else: + print(" ✗ test2.txt NOT restored correctly") + print(f" Expected: {repr(expected_content[:50])}") + print(f" Actual: {repr(actual_content[:50])}") + files_restored_correctly = False + file_check_results.append(("test2.txt", False, "content mismatch")) + else: + print(" ✗ test2.txt does not exist") + files_restored_correctly = False + file_check_results.append(("test2.txt", False, "missing")) + + # Check test3.txt - should be restored to initial content + test3_path = working_dir / "subdir/test3.txt" + if test3_path.exists(): + actual_content = test3_path.read_text() + expected_content = initial_files["subdir/test3.txt"] + if actual_content == expected_content: + print(" ✓ subdir/test3.txt restored correctly") + file_check_results.append(("subdir/test3.txt", True, "restored")) + else: + print(" ✗ subdir/test3.txt NOT restored correctly") + print(f" Expected: {repr(expected_content[:50])}") + print(f" Actual: {repr(actual_content[:50])}") + files_restored_correctly = False + file_check_results.append(("subdir/test3.txt", False, "content mismatch")) + else: + print(" ✗ subdir/test3.txt does not exist") + files_restored_correctly = False + file_check_results.append(("subdir/test3.txt", False, "missing")) + + # Note: test4.txt was created after checkpoint 1, so it's not included in the restoration. + # Whether the client deletes it or leaves it is a client implementation detail, + # so we don't test for its presence/absence. + + if not files_restored_correctly: + print("\n ⚠️ WARNING: Files were NOT restored correctly!") + print(" This indicates the checkpoint restoration feature is not working.") else: - print(" ⚠ No file_write tool calls found (checkpoint may be empty or not created)") + print("\n ✓ All files restored correctly to checkpoint 1 state!") # Print summary print("") print(SEPARATOR) - print("Thread Checkpoint Test PASSED") + if files_restored_correctly: + print("Thread Checkpoint Test PASSED") + else: + print("Thread Checkpoint Test FAILED - Files Not Restored") print(SEPARATOR) print("") print("Summary:") @@ -353,8 +532,25 @@ def test_thread_checkpoint_basic( print(f" Messages before edit: {message_count_before}") print(f" Messages after edit: {message_count_after}") print(f" Messages deleted: {message_count_before - message_count_after}") + print(f" LLM processing detected: {'Yes' if processing_detected else 'No'}") + print(" Post-edit streaming events:") + print(f" ContentStart: {post_edit_content_start}") + print(f" ContentDelta: {post_edit_content_delta}") + print(f" ContentStop: {post_edit_content_stop}") + print(f" File restorations returned: {len(file_restorations)}") + print(f" Files restored correctly: {'Yes' if files_restored_correctly else 'No'}") + print(" File restoration details:") + for file_name, success, status in file_check_results: + status_icon = "✓" if success else "✗" + print(f" {status_icon} {file_name}: {status}") print("") + # Assert file restoration worked (critical for test to pass) + assert files_restored_correctly, ( + "File restoration failed - files were not restored to checkpoint state. " + f"Check results: {file_check_results}" + ) + # Add extra info to summary test_summary.extra_info["Working directory"] = str(working_dir) test_summary.extra_info["Messages before edit"] = str(message_count_before) @@ -362,4 +558,14 @@ def test_thread_checkpoint_basic( test_summary.extra_info["Messages deleted"] = str( message_count_before - message_count_after ) - test_summary.extra_info["File restoration tool calls"] = "Yes" if has_file_writes else "No" + test_summary.extra_info["LLM processing detected"] = "Yes" if processing_detected else "No" + test_summary.extra_info["Post-edit ContentStart"] = str(post_edit_content_start) + test_summary.extra_info["Post-edit ContentDelta"] = str(post_edit_content_delta) + test_summary.extra_info["Post-edit ContentStop"] = str(post_edit_content_stop) + test_summary.extra_info["File restorations returned"] = str(len(file_restorations)) + test_summary.extra_info["Files restored correctly"] = ( + "Yes" if files_restored_correctly else "No" + ) + test_summary.extra_info["File restoration details"] = ", ".join( + f"{name}: {status}" for name, _, status in file_check_results + ) diff --git a/tim-api/internal/mapper/thread.go b/tim-api/internal/mapper/thread.go index 80ed8b75b..a5924ccd9 100644 --- a/tim-api/internal/mapper/thread.go +++ b/tim-api/internal/mapper/thread.go @@ -265,7 +265,7 @@ func MessageContentsToProto(contents []db.LlmMessageContent, log *logger.Logger) } // MessageToProto converts a database message with contents to proto format -func MessageToProto(message db.LlmMessage, contents []db.LlmMessageContent, path *resourcepath.ThreadMessagePath, log *logger.Logger) *threadv1.LlmMessage { +func MessageToProto(message db.LlmMessage, contents []db.LlmMessageContent, path *resourcepath.ThreadMessagePath, hasCheckpoint bool, log *logger.Logger) *threadv1.LlmMessage { // Convert contents contentProtos := MessageContentsToProto(contents, log) @@ -275,11 +275,12 @@ func MessageToProto(message db.LlmMessage, contents []db.LlmMessageContent, path } // Build message proto messageProto := &threadv1.LlmMessage{ - Path: path.String(), - OriginThread: originThreadPath.String(), - Index: message.Idx, - Role: MessageRoleToProto(message.Role), - Contents: contentProtos, + Path: path.String(), + OriginThread: originThreadPath.String(), + Index: message.Idx, + Role: MessageRoleToProto(message.Role), + Contents: contentProtos, + HasCheckpoint: hasCheckpoint, } if message.ModelID.Valid { diff --git a/tim-api/internal/services/thread/checkpoint.go b/tim-api/internal/services/thread/checkpoint.go index daf4c3c18..688f3c24f 100644 --- a/tim-api/internal/services/thread/checkpoint.go +++ b/tim-api/internal/services/thread/checkpoint.go @@ -14,10 +14,7 @@ import ( "strings" "time" - "github.com/Greybox-Labs/tim/shared/llm" "github.com/Greybox-Labs/tim/tim-api/internal/database" - "github.com/Greybox-Labs/tim/tim-api/internal/natsnotifier" - "github.com/Greybox-Labs/tim/tim-api/internal/resourcepath" "github.com/Greybox-Labs/tim/tim-db/gen/db" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -162,6 +159,13 @@ func (s *Service) scanDirectory(ctx context.Context, workingDir string, config * if info.IsDir() { // Check if directory should be ignored relPath, _ := filepath.Rel(workingDir, path) + + // Always skip .git directory and other VCS directories + if s.isVCSDirectory(relPath) { + s.logger.Debugw("skipping VCS directory", "path", relPath) + return filepath.SkipDir + } + if s.shouldIgnore(relPath, gitignorePatterns, true) { return filepath.SkipDir } @@ -351,6 +355,11 @@ func (s *Service) computeLineDiff(oldLines, newLines []string) *DiffData { // reconstructFileContent reconstructs file content at a given checkpoint func (s *Service) reconstructFileContent(ctx context.Context, queries *db.Queries, threadUID uuid.UUID, checkpointUID uuid.UUID, filePath string) ([]byte, error) { + s.logger.Debugw("reconstructing file content", + "thread_uid", threadUID, + "checkpoint_uid", checkpointUID, + "file_path", filePath) + // Get the base snapshot for this file baseSnapshot, err := queries.GetBaseSnapshotForFile(ctx, db.GetBaseSnapshotForFileParams{ ThreadUID: threadUID, @@ -358,9 +367,19 @@ func (s *Service) reconstructFileContent(ctx context.Context, queries *db.Querie CheckpointUid: checkpointUID, }) if err != nil { + s.logger.Errorw("failed to get base snapshot", + "thread_uid", threadUID, + "checkpoint_uid", checkpointUID, + "file_path", filePath, + "error", err) return nil, fmt.Errorf("failed to get base snapshot: %w", err) } + s.logger.Debugw("found base snapshot", + "file_path", filePath, + "base_checkpoint_uid", baseSnapshot.CheckpointUid, + "is_base", baseSnapshot.IsBaseSnapshot) + // Decode base content var baseData map[string]interface{} err = json.Unmarshal(baseSnapshot.Content, &baseData) @@ -468,6 +487,27 @@ func (s *Service) loadGitignorePatterns(workingDir string) []string { return patterns } +// isVCSDirectory checks if a path is a version control system directory +func (s *Service) isVCSDirectory(path string) bool { + // Check if path starts with or contains VCS directory names + vcsDirectories := []string{".git", ".svn", ".hg", ".bzr"} + + baseName := filepath.Base(path) + for _, vcs := range vcsDirectories { + if baseName == vcs { + return true + } + // Also check if it's a subdirectory of a VCS directory + if strings.HasPrefix(path, vcs+string(filepath.Separator)) { + return true + } + if strings.Contains(path, string(filepath.Separator)+vcs+string(filepath.Separator)) { + return true + } + } + return false +} + // shouldIgnore checks if a path should be ignored based on patterns func (s *Service) shouldIgnore(path string, patterns []string, isDir bool) bool { for _, pattern := range patterns { @@ -525,116 +565,3 @@ func (s *Service) GetCheckpointForMessage(ctx context.Context, messageUID uuid.U return &checkpoint, snapshotPtrs, nil } - -// sendFileRestorationToolCalls sends file_write tool calls for checkpoint restoration -func (s *Service) sendFileRestorationToolCalls( - ctx context.Context, - queries *db.Queries, - threadPath *resourcepath.ThreadPath, - checkpoint db.ThreadCheckpoint, - fileSnapshots []db.CheckpointFileSnapshot, -) error { - // Get the last message to determine the next index - lastMessage, err := queries.GetLastThreadMessage(ctx, threadPath.ThreadUID) - if err != nil { - s.logger.Errorw("failed to get last message", "error", err) - return fmt.Errorf("failed to get last message: %w", err) - } - - nextIdx := lastMessage.Idx + 1 - - // Create a system assistant message to hold the restoration tool calls - restorationMessage, err := queries.CreateMessage(ctx, db.CreateMessageParams{ - OriginThreadUID: threadPath.ThreadUID, - Idx: nextIdx, - Role: db.LlmMessageRoleAssistant, - StreamStatus: db.LlmMessageStreamStatusComplete, - }) - if err != nil { - s.logger.Errorw("failed to create restoration message", "error", err) - return fmt.Errorf("failed to create restoration message: %w", err) - } - - // Add the message to the thread - err = queries.AddMessageToThread(ctx, db.AddMessageToThreadParams{ - ThreadUID: threadPath.ThreadUID, - MessageUID: restorationMessage.UID, - }) - if err != nil { - s.logger.Errorw("failed to add restoration message to thread", "error", err) - return fmt.Errorf("failed to add restoration message to thread: %w", err) - } - - s.logger.Infow("created checkpoint restoration message", - "message_uid", restorationMessage.UID, - "thread_uid", threadPath.ThreadUID, - "file_count", len(fileSnapshots)) - - // Create NATS notifier for sending events - notifier := natsnotifier.NewThreadEventNotifier(ctx, s.nats, threadPath, s.logger) - - // For each file snapshot, reconstruct content and create a file_write tool call - for idx, snapshot := range fileSnapshots { - // Reconstruct file content at this checkpoint - fileContent, err := s.reconstructFileContent(ctx, queries, threadPath.ThreadUID, checkpoint.UID, snapshot.FilePath) - if err != nil { - s.logger.Warnw("failed to reconstruct file content, skipping", - "file", snapshot.FilePath, - "error", err) - continue - } - - // Create file_write tool input - toolInput := map[string]interface{}{ - "path": snapshot.FilePath, - "content": string(fileContent), - } - - toolInputJSON, err := json.Marshal(toolInput) - if err != nil { - s.logger.Errorw("failed to marshal tool input", "file", snapshot.FilePath, "error", err) - continue - } - - // Create tool use content - toolUseContent := llm.ToolUseContent{ - ID: llm.GenerateToolUseID(), - Name: string(llm.ToolFileWrite), - Input: toolInputJSON, - } - - contentJSON, err := json.Marshal(toolUseContent) - if err != nil { - s.logger.Errorw("failed to marshal tool use content", "file", snapshot.FilePath, "error", err) - continue - } - - // Create the content block for this file_write tool call - content, err := queries.CreateMessageContent(ctx, db.CreateMessageContentParams{ - MessageUID: restorationMessage.UID, - Idx: int32(idx), - Type: db.LlmMessageContentTypeToolUse, - Content: contentJSON, - StreamStatus: db.LlmMessageStreamStatusComplete, - }) - if err != nil { - s.logger.Errorw("failed to create tool use content", "file", snapshot.FilePath, "error", err) - continue - } - - s.logger.Infow("created file restoration tool call", - "file", snapshot.FilePath, - "tool_use_id", toolUseContent.ID, - "content_uid", content.UID, - "content_idx", idx) - - // Send notification for this tool call - notifier.NotifyToolCall(toolUseContent.ID) - } - - s.logger.Infow("sent all file restoration tool calls", - "checkpoint_uid", checkpoint.UID, - "file_count", len(fileSnapshots)) - - return nil -} diff --git a/tim-api/internal/services/thread/handlers.go b/tim-api/internal/services/thread/handlers.go index d84932dc8..6d8ce5a99 100644 --- a/tim-api/internal/services/thread/handlers.go +++ b/tim-api/internal/services/thread/handlers.go @@ -317,6 +317,10 @@ func (s *Service) CreateThread( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } + // Note: Checkpoint creation for the initial message happens when the working directory + // is configured via ConfigureThreadWorkingDirectory, which creates retroactive checkpoints + // for any existing user messages + // Update thread status to processing err = queries.UpdateThreadLLMStatus(ctx, db.UpdateThreadLLMStatusParams{ ThreadUID: path.ThreadUID, diff --git a/tim-api/internal/services/thread/message_handlers.go b/tim-api/internal/services/thread/message_handlers.go index 26be21403..e0e1c61bd 100644 --- a/tim-api/internal/services/thread/message_handlers.go +++ b/tim-api/internal/services/thread/message_handlers.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "connectrpc.com/connect" "github.com/Greybox-Labs/tim/shared/llm" @@ -34,7 +35,7 @@ const ( func (s *Service) EditThreadMessage( ctx context.Context, req *connect.Request[threadv1.EditThreadMessageRequest], -) (*connect.Response[threadv1.LlmMessage], error) { +) (*connect.Response[threadv1.EditThreadMessageResponse], error) { authzHandle, err := authz.HandlerFromContext(ctx) if err != nil { s.logger.Errorw("failed to get authz handle from context", "error", err, "path", req.Msg.Path) @@ -73,56 +74,11 @@ func (s *Service) EditThreadMessage( return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only user messages can be updated")) } - // Check if there's a checkpoint at this message index + // Check if there's a checkpoint at this message index (for restoration if requested) checkpoint, err := queries.GetCheckpointByMessage(ctx, messagePath.ThreadMessageUID) checkpointExists := err == nil - var fileSnapshots []db.CheckpointFileSnapshot - if checkpointExists { - fileSnapshots, err = queries.ListFileSnapshots(ctx, checkpoint.UID) - if err != nil { - s.logger.Errorw("failed to list file snapshots", "error", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) - } - } - - // If checkpoint exists with files and user hasn't confirmed, return error with details - if checkpointExists && len(fileSnapshots) > 0 && !req.Msg.ConfirmRestore { - // Count messages that will be deleted (all messages after this one) - threadMessages, err := queries.ListThreadMessages(ctx, db.ListThreadMessagesParams{ - ThreadUID: message.OriginThreadUID, - PageLimit: 1000, // Large enough to get all messages - }) - if err != nil { - s.logger.Errorw("failed to list thread messages", "error", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) - } - - messagesToDelete := 0 - for _, tm := range threadMessages { - if tm.Idx > message.Idx { - messagesToDelete++ - } - } - - // Collect affected file paths - affectedPaths := make([]string, 0, len(fileSnapshots)) - for _, snapshot := range fileSnapshots { - affectedPaths = append(affectedPaths, snapshot.FilePath) - } - - // Return error with checkpoint restoration details - errorMsg := fmt.Sprintf( - "checkpoint restoration required: %d files will be restored, %d messages will be deleted. Affected files: %v. Set confirm_restore=true to proceed.", - len(fileSnapshots), - messagesToDelete, - affectedPaths, - ) - - return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New(errorMsg)) - } - - // User has confirmed or no checkpoint exists - proceed with update + // Proceed with update // Delete all messages after this one err = queries.DeleteMessagesAfterIndex(ctx, db.DeleteMessagesAfterIndexParams{ ThreadUID: message.OriginThreadUID, @@ -160,20 +116,102 @@ func (s *Service) EditThreadMessage( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } - // If checkpoint exists with files, restore them via tool execution - if checkpointExists && len(fileSnapshots) > 0 { - s.logger.Infow("checkpoint restoration required", - "checkpoint_uid", checkpoint.UID, - "file_count", len(fileSnapshots), - "thread_uid", messagePath.Parent.ThreadUID) + // Build file restoration data only if checkpoint exists AND user requested restoration + // Initialize to empty slice (not nil) to ensure it's always serialized in JSON + fileRestorations := make([]*threadv1.FileRestoration, 0) + // Check if restore was explicitly set to true (optional field, so check pointer) + shouldRestore := req.Msg.Restore != nil && *req.Msg.Restore + if checkpointExists && shouldRestore { + // Get file snapshots for this checkpoint + fileSnapshots, err := queries.ListFileSnapshots(ctx, checkpoint.UID) + if err != nil { + s.logger.Errorw("failed to list file snapshots", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + if len(fileSnapshots) > 0 { + s.logger.Infow("checkpoint restoration requested", + "checkpoint_uid", checkpoint.UID, + "thread_uid", messagePath.Parent.ThreadUID) + + // Get ALL file paths that existed at or before this checkpoint + // This includes files from previous checkpoints that haven't changed + filePaths, err := queries.ListAllFilePathsAtCheckpoint(ctx, db.ListAllFilePathsAtCheckpointParams{ + ThreadUID: messagePath.Parent.ThreadUID, + CheckpointUid: checkpoint.UID, + }) + if err != nil { + s.logger.Errorw("failed to list file paths at checkpoint", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + s.logger.Infow("found files at checkpoint", + "checkpoint_uid", checkpoint.UID, + "file_count", len(filePaths), + "thread_uid", messagePath.Parent.ThreadUID) + + fileRestorations = make([]*threadv1.FileRestoration, 0, len(filePaths)) + for _, filePath := range filePaths { + // Reconstruct file content at this checkpoint + fileContent, err := s.reconstructFileContent(ctx, queries, messagePath.Parent.ThreadUID, checkpoint.UID, filePath) + if err != nil { + s.logger.Errorw("failed to reconstruct file content, skipping", + "file", filePath, + "checkpoint_uid", checkpoint.UID, + "thread_uid", messagePath.Parent.ThreadUID, + "error", err, + "error_type", fmt.Sprintf("%T", err)) + continue + } + + // Create absolute path by joining working directory with relative file path + absolutePath := filepath.Join(checkpoint.WorkingDirectory, filePath) + + fileRestorations = append(fileRestorations, &threadv1.FileRestoration{ + Path: absolutePath, + Content: fileContent, + }) + + s.logger.Infow("prepared file restoration", + "file", filePath, + "absolute_path", absolutePath, + "size", len(fileContent)) + } - // Send file restoration tool calls through the stream - if err := s.sendFileRestorationToolCalls(ctx, queries, messagePath.Parent, checkpoint, fileSnapshots); err != nil { - s.logger.Errorw("failed to send file restoration tool calls", "error", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to restore checkpoint files")) + s.logger.Infow("file restoration preparation complete", + "checkpoint_uid", checkpoint.UID, + "total_files_at_checkpoint", len(filePaths), + "successful_restorations", len(fileRestorations), + "failed_restorations", len(filePaths)-len(fileRestorations)) } } + // Update thread status to processing to trigger LLM response + err = queries.UpdateThreadLLMStatus(ctx, db.UpdateThreadLLMStatusParams{ + ThreadUID: message.OriginThreadUID, + LlmStatus: db.ThreadLlmStatusProcessing, + }) + if err != nil { + s.logger.Errorw("failed to update thread llm status", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Notify about thread state change + notifier := natsnotifier.NewThreadEventNotifier(ctx, s.nats, messagePath.Parent, s.logger) + notifier.NotifyThreadStateChange(db.ThreadLlmStatusProcessing) + + // Enqueue LLM relay job after transaction commits + if tx, ok := database.GetLazyTx(ctx); ok { + threadPath := messagePath.Parent.String() + tx.OnCommit(func() { + if err := s.jobQueue.PushLLMRelayJob(threadPath); err != nil { + s.logger.Errorw("failed to enqueue llm_relay job", "error", err, "thread", threadPath) + } else { + s.logger.Debugw("enqueued llm_relay job", "thread", threadPath) + } + }) + } + // Get updated message to return updatedMessage, err := s.GetLlmMessage(ctx, connect.NewRequest(&threadv1.GetLlmMessageRequest{ Path: req.Msg.Path, @@ -183,7 +221,19 @@ func (s *Service) EditThreadMessage( return nil, err } - return connect.NewResponse(updatedMessage.Msg), nil + response := &threadv1.EditThreadMessageResponse{ + Message: updatedMessage.Msg, + FileRestorations: fileRestorations, + } + + s.logger.Infow("Message edited successfully, LLM relay job enqueued", + "message_path", req.Msg.Path, + "thread_uid", messagePath.Parent.ThreadUID, + "file_restorations_count", len(fileRestorations), + "file_restorations_nil", fileRestorations == nil, + "response_file_restorations_count", len(response.FileRestorations)) + + return connect.NewResponse(response), nil } // ConfigureThreadWorkingDirectory configures the working directory for a thread (for checkpoint creation) @@ -224,6 +274,43 @@ func (s *Service) ConfigureThreadWorkingDirectory( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } + // If the thread already has user messages without checkpoints, create checkpoints for them + // This handles the case where a thread is created with an initial message before the working directory is configured + messages, err := queries.ListThreadMessages(ctx, db.ListThreadMessagesParams{ + ThreadUID: threadPath.ThreadUID, + PageLimit: 1000, // Large enough to get all messages + }) + if err != nil { + s.logger.Warnw("failed to list thread messages for retroactive checkpoint creation", "error", err) + } else { + for _, message := range messages { + // Only create checkpoints for user messages + if message.Role != db.LlmMessageRoleUser { + continue + } + + // Check if checkpoint already exists for this message + _, err := queries.GetCheckpointByMessage(ctx, message.UID) + if err == nil { + // Checkpoint already exists, skip + continue + } + + // Create checkpoint for this message + if err := s.CreateCheckpoint(ctx, threadPath.ThreadUID, message.UID, req.Msg.WorkingDirectory); err != nil { + s.logger.Errorw("failed to create retroactive checkpoint", + "error", err, + "thread_uid", threadPath.ThreadUID, + "message_uid", message.UID) + // Continue with other messages + } else { + s.logger.Infow("created retroactive checkpoint for existing message", + "thread_uid", threadPath.ThreadUID, + "message_uid", message.UID) + } + } + } + s.logger.Infow("updated thread working directory", "thread_uid", threadPath.ThreadUID, "working_directory", req.Msg.WorkingDirectory) @@ -276,8 +363,12 @@ func (s *Service) GetLlmMessage( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } + // Check if a checkpoint exists for this message + _, err = queries.GetCheckpointByMessage(ctx, message.UID) + hasCheckpoint := err == nil + // Build thread path and convert to proto - messageProto := mapper.MessageToProto(message, contents, path, s.logger) + messageProto := mapper.MessageToProto(message, contents, path, hasCheckpoint, s.logger) return connect.NewResponse(messageProto), nil } @@ -385,8 +476,12 @@ func (s *Service) ListLlmMessages( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } + // Check if a checkpoint exists for this message + _, err = queries.GetCheckpointByMessage(ctx, message.UID) + hasCheckpoint := err == nil + // Convert to proto using mapper - messageProtos[i] = mapper.MessageToProto(message, contents, path, s.logger) + messageProtos[i] = mapper.MessageToProto(message, contents, path, hasCheckpoint, s.logger) } response := &threadv1.ListLlmMessagesResponse{ @@ -936,7 +1031,8 @@ func (s *Service) SubmitUserMessage( // Build response content := []db.LlmMessageContent{contentEntry} - proto := mapper.MessageToProto(message, content, path, s.logger) + // New messages don't have checkpoints yet + proto := mapper.MessageToProto(message, content, path, false, s.logger) s.logger.Infow( "User message created", diff --git a/tim-api/internal/services/thread_context/handlers.go b/tim-api/internal/services/thread_context/handlers.go index c7d001d47..a90468458 100644 --- a/tim-api/internal/services/thread_context/handlers.go +++ b/tim-api/internal/services/thread_context/handlers.go @@ -64,7 +64,11 @@ func (s *Service) GetThreadContext( Parent: parentPath, ThreadMessageUID: message.UID, } - messageProto := mapper.MessageToProto(message, contents, path, s.logger) + // Check if a checkpoint exists for this message + _, err = queries.GetCheckpointByMessage(ctx, message.UID) + hasCheckpoint := err == nil + + messageProto := mapper.MessageToProto(message, contents, path, hasCheckpoint, s.logger) messageProtos = append(messageProtos, messageProto) } diff --git a/tim-cli-v2/cmd/root.go b/tim-cli-v2/cmd/root.go index 73981dd7a..2ad97592e 100644 --- a/tim-cli-v2/cmd/root.go +++ b/tim-cli-v2/cmd/root.go @@ -3,7 +3,9 @@ package cmd import ( "context" "fmt" + "io" "log" + "log/slog" "os" "time" @@ -260,6 +262,31 @@ func executeSingleQuery(ctx context.Context, timClient *client.TimAPIClient, per func runInteractiveMode(timClient *client.TimAPIClient, personaUID, threadID string) error { debugLog("runInteractiveMode: personaUID=%s, threadID=%s", personaUID, threadID) + + // Disable slog output to prevent interference with Bubble Tea TUI + // Save the original logger so we can restore it after TUI exits (if needed) + originalLogger := slog.Default() + + // If debug logging is enabled, redirect slog to the debug log file + // Otherwise, disable it completely + if debugLogger != nil && debugFile != nil { + debugLog("Redirecting slog to debug log file for TUI mode") + slogHandler := slog.NewTextHandler(debugFile, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + slog.SetDefault(slog.New(slogHandler)) + } else { + debugLog("Disabling slog output for TUI mode") + // Create a no-op logger that discards all output + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + } + + // Restore the original logger when we exit (defer ensures it runs even on panic) + defer func() { + slog.SetDefault(originalLogger) + debugLog("Restored original slog logger") + }() + model := tui.NewModelWithThread(timClient, personaUID, threadID, debugLogger) p := tea.NewProgram(model, tea.WithAltScreen()) diff --git a/tim-cli-v2/internal/client/client.go b/tim-cli-v2/internal/client/client.go index 0ff153299..1a518aa26 100644 --- a/tim-cli-v2/internal/client/client.go +++ b/tim-cli-v2/internal/client/client.go @@ -264,23 +264,46 @@ func (c *TimAPIClient) ListPersonas(ctx context.Context) (*personav1alpha1.ListP return resp, nil } -// EditThreadMessage edits a message on a thread -func (c *TimAPIClient) EditThreadMessage(ctx context.Context, messagePath, newContent string, confirmRestore bool) (*threadv1alpha1.LlmMessage, error) { - c.debugLog("[client] EditThreadMessage: messagePath=%s, contentLen=%d, confirmRestore=%v", messagePath, len(newContent), confirmRestore) +// EditThreadMessage edits a message on a thread. +// +// If restore is set to true and a checkpoint exists, the API will return file_restorations +// in the response that the client must apply. +// +// The caller is responsible for: +// 1. Checking message.has_checkpoint before editing to show confirmation UI +// 2. Writing FileRestorations to disk if returned in the response +func (c *TimAPIClient) EditThreadMessage(ctx context.Context, messagePath, newContent string, restore *bool) (*threadv1alpha1.EditThreadMessageResponse, error) { + c.debugLog("[client] EditThreadMessage: messagePath=%s, contentLen=%d, restore=%v", messagePath, len(newContent), restore) req := connect.NewRequest(&threadv1alpha1.EditThreadMessageRequest{ - Path: messagePath, - Content: newContent, - ConfirmRestore: confirmRestore, + Path: messagePath, + Content: newContent, + Restore: restore, }) resp, err := c.client.Thread.EditThreadMessage(ctx, req) if err != nil { c.debugLog("[client] EditThreadMessage failed: %v", err) return nil, err } - c.debugLog("[client] EditThreadMessage successful") + c.debugLog("[client] EditThreadMessage successful, file_restorations=%d", len(resp.Msg.FileRestorations)) return resp.Msg, nil } +// ConfigureThreadWorkingDirectory configures the working directory for a thread (for checkpoint creation) +func (c *TimAPIClient) ConfigureThreadWorkingDirectory(ctx context.Context, threadPath, workingDirectory string) error { + c.debugLog("[client] ConfigureThreadWorkingDirectory: threadPath=%s, workingDir=%s", threadPath, workingDirectory) + req := connect.NewRequest(&threadv1alpha1.ConfigureThreadWorkingDirectoryRequest{ + Path: threadPath, + WorkingDirectory: workingDirectory, + }) + _, err := c.client.Thread.ConfigureThreadWorkingDirectory(ctx, req) + if err != nil { + c.debugLog("[client] ConfigureThreadWorkingDirectory failed: %v", err) + return err + } + c.debugLog("[client] ConfigureThreadWorkingDirectory successful") + return nil +} + // Ping tests API connectivity func (c *TimAPIClient) Ping(ctx context.Context) error { c.debugLog("[client] Ping: testing connectivity") diff --git a/tim-cli-v2/internal/tui/model.go b/tim-cli-v2/internal/tui/model.go index 88310a2bb..6ecda6975 100644 --- a/tim-cli-v2/internal/tui/model.go +++ b/tim-cli-v2/internal/tui/model.go @@ -1,13 +1,17 @@ package tui import ( + "bufio" + "bytes" "context" "encoding/json" "fmt" + "io/fs" "log" "math" "math/rand" "os" + "path/filepath" "regexp" "strings" "time" @@ -39,6 +43,7 @@ const ( ViewModeChat ViewMode = iota ViewModeMessageList ViewModeEditing + ViewModeConfirmRestore ) // Styles @@ -156,7 +161,17 @@ type ( } // messageEditedMsg is sent when a message is edited successfully - messageEditedMsg struct{} + messageEditedMsg struct { + filesRestored int + filesDeleted int + } + + // restoreRequiredMsg is sent when checkpoint restoration is required + restoreRequiredMsg struct { + messagePath string + content string + origContent string + } ) // Model represents the chat TUI state @@ -187,8 +202,14 @@ type Model struct { selectedMessageIdx int // Index of selected message in message viewer editingMessagePath string // Path of message being edited editingMessageOrig string // Original content before editing + editingMessage *threadv1alpha1.LlmMessage // Full message being edited (for checkpoint check) messageListViewport viewport.Model // Viewport for message list + // Restore confirmation state + restoreConfirmMessagePath string // Path of message that needs restore confirmation + restoreConfirmContent string // New content for the message + restoreConfirmOrigContent string // Original content before editing + // Clarify tool state awaitingClarification bool // True when waiting for user's clarification answer clarifyQuestion string // The question to ask the user @@ -309,16 +330,37 @@ func calculateBackoffWithJitter(attempt int, baseDelay, maxDelay time.Duration) func (m Model) Init() tea.Cmd { m.debugLog("Init called") - // If we have an existing thread, open stream immediately + // If we have an existing thread, configure working directory and open stream if m.threadID != "" { threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) - m.debugLog("Init: opening stream for existing thread: %s", threadPath) - return tea.Batch(textarea.Blink, m.listenToStream(threadPath)) + m.debugLog("Init: configuring working directory and opening stream for existing thread: %s", threadPath) + return tea.Batch(textarea.Blink, m.configureWorkingDirectory(threadPath), m.listenToStream(threadPath)) } return textarea.Blink } +// configureWorkingDirectory configures the working directory for a thread +func (m *Model) configureWorkingDirectory(threadPath string) tea.Cmd { + return func() tea.Msg { + workDir, err := os.Getwd() + if err != nil { + m.debugLog("configureWorkingDirectory: failed to get working directory: %v", err) + return nil // Don't fail if we can't get working directory + } + + m.debugLog("configureWorkingDirectory: configuring %s for thread %s", workDir, threadPath) + if err := m.client.ConfigureThreadWorkingDirectory(m.ctx, threadPath, workDir); err != nil { + m.debugLog("configureWorkingDirectory: failed to configure: %v", err) + // Don't fail the request if working directory configuration fails + } else { + m.debugLog("configureWorkingDirectory: successfully configured") + } + + return nil // Return nil to avoid affecting the flow + } +} + // debugLog writes a debug log message if debug logging is enabled func (m *Model) debugLog(format string, args ...interface{}) { if m.debugLogger != nil { @@ -333,13 +375,173 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { vpCmd tea.Cmd ) + // Handle keyboard input - check for special keys before updating textarea + if keyMsg, ok := msg.(tea.KeyMsg); ok { + m.debugLog("KeyMsg received: %s (viewMode=%d)", keyMsg.String(), m.viewMode) + + // In confirm restore mode, intercept all keys to prevent textarea updates + if m.viewMode == ViewModeConfirmRestore { + switch keyMsg.String() { + case "y", "Y": + // User confirmed - restore with checkpoint + m.debugLog("User confirmed checkpoint restoration") + messagePath := m.restoreConfirmMessagePath + content := m.restoreConfirmContent + origContent := m.restoreConfirmOrigContent + + // Clear confirmation state + m.restoreConfirmMessagePath = "" + m.restoreConfirmContent = "" + m.restoreConfirmOrigContent = "" + // Clear editing state + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + + // Switch back to chat view + m.viewMode = ViewModeChat + + // Reset textarea for chat mode + m.textarea.Blur() + m.textarea.Reset() + m.textarea.Focus() + + m.addMessage("Restoring checkpoint and saving edited message...", "system") + m.updateViewport() + + // Save with restoration (restore = true) + restoreTrue := true + return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreTrue) + + case "n", "N": + // User declined restoration - save without restoring (new checkpoint replaces old) + m.debugLog("User declined checkpoint restoration, saving without restore") + messagePath := m.restoreConfirmMessagePath + content := m.restoreConfirmContent + origContent := m.restoreConfirmOrigContent + + // Clear confirmation state + m.restoreConfirmMessagePath = "" + m.restoreConfirmContent = "" + m.restoreConfirmOrigContent = "" + // Clear editing state + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + + // Switch back to chat view + m.viewMode = ViewModeChat + + // Reset textarea for chat mode + m.textarea.Blur() + m.textarea.Reset() + m.textarea.Focus() + + m.addMessage("Saving edited message without checkpoint restoration...", "system") + m.updateViewport() + + // Save without restoration (restore = false) + restoreFalse := false + return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreFalse) + + case "esc": + // User cancelled - return to message list + m.debugLog("User cancelled message edit") + m.restoreConfirmMessagePath = "" + m.restoreConfirmContent = "" + m.restoreConfirmOrigContent = "" + // Clear editing state + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + + // Reset textarea + m.textarea.Blur() + m.textarea.Reset() + + m.viewMode = ViewModeMessageList + return m, nil + } + // For any other key in confirm restore mode, just ignore it + return m, nil + } + + // In editing mode, intercept Enter to save (but allow Alt+Enter for new lines) + if m.viewMode == ViewModeEditing { + switch keyMsg.Type { + case tea.KeyEsc: + // Cancel editing + m.debugLog("Esc pressed while editing, canceling") + + // Clear editing state + m.editingMessagePath = "" + m.editingMessageOrig = "" + + // Switch back to message list + m.viewMode = ViewModeMessageList + m.textarea.Blur() + m.textarea.Reset() + return m, nil + + case tea.KeyEnter: + // Check if Alt is held (Alt+Enter should add a new line) + if keyMsg.Alt { + // Alt+Enter - let textarea handle it for new line + m.textarea, tiCmd = m.textarea.Update(msg) + return m, tiCmd + } + // Plain Enter - check if checkpoint exists and show dialog if needed + m.debugLog("Enter pressed, checking for checkpoint") + + newContent := strings.TrimSpace(m.textarea.Value()) + messagePath := m.editingMessagePath + origContent := m.editingMessageOrig + editingMessage := m.editingMessage + + // Check if message has a checkpoint + if editingMessage != nil && editingMessage.HasCheckpoint { + m.debugLog("Message has checkpoint, showing confirmation dialog") + // Store confirmation state + m.restoreConfirmMessagePath = messagePath + m.restoreConfirmContent = newContent + m.restoreConfirmOrigContent = origContent + // Switch to confirmation view (keep editing state for now) + m.viewMode = ViewModeConfirmRestore + return m, nil + } + + // No checkpoint, save directly + m.debugLog("No checkpoint, saving directly") + + // Clear editing state + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + + // Switch back to chat view + m.viewMode = ViewModeChat + + // Properly reset textarea for chat mode + m.textarea.Blur() + m.textarea.Reset() + m.textarea.Focus() + + // Add status message and update viewport + m.addMessage("Saving edited message...", "system") + m.updateViewport() + + // Save without restoration (no checkpoint exists) + return m, m.saveEditedMessageAsync(messagePath, origContent, newContent, nil) + } + } + } + + // Update textarea and viewport (except when we've already handled it above) m.textarea, tiCmd = m.textarea.Update(msg) m.viewport, vpCmd = m.viewport.Update(msg) switch msg := msg.(type) { case tea.KeyMsg: - m.debugLog("KeyMsg received: %s (viewMode=%d)", msg.String(), m.viewMode) - // Handle view mode specific keys switch m.viewMode { case ViewModeChat: @@ -378,44 +580,52 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - if m.selectedMessageIdx > 0 { - m.selectedMessageIdx-- - m.updateMessageListViewport() + // Move up to the next selectable message + newIdx := m.selectedMessageIdx - 1 + for newIdx >= 0 { + if m.isSelectableMessage(m.llmMessages[newIdx]) { + m.selectedMessageIdx = newIdx + m.updateMessageListViewport() + break + } + newIdx-- } return m, nil case tea.KeyDown: - if m.selectedMessageIdx < len(m.llmMessages)-1 { - m.selectedMessageIdx++ - m.updateMessageListViewport() + // Move down to the next selectable message + newIdx := m.selectedMessageIdx + 1 + for newIdx < len(m.llmMessages) { + if m.isSelectableMessage(m.llmMessages[newIdx]) { + m.selectedMessageIdx = newIdx + m.updateMessageListViewport() + break + } + newIdx++ } return m, nil case tea.KeyEnter: - // Edit the selected message + // Edit the selected message (only if it's selectable) if m.selectedMessageIdx < len(m.llmMessages) { msg := m.llmMessages[m.selectedMessageIdx] - m.debugLog("Enter pressed on message %d, starting edit", m.selectedMessageIdx) - return m, m.startEditingMessage(msg) + if m.isSelectableMessage(msg) { + m.debugLog("Enter pressed on message %d, starting edit", m.selectedMessageIdx) + return m, m.startEditingMessage(msg) + } else { + m.debugLog("Enter pressed on non-selectable message %d, ignoring", m.selectedMessageIdx) + } } return m, nil } case ViewModeEditing: - switch msg.Type { - case tea.KeyEsc: - // Cancel editing - m.debugLog("Esc pressed while editing, canceling") - m.viewMode = ViewModeMessageList - m.textarea.Reset() - m.textarea.Blur() - return m, nil + // Editing mode keys are handled above before textarea.Update() + // to properly intercept Enter before textarea processes it - case tea.KeyCtrlS: - // Save the edit - m.debugLog("Ctrl+S pressed, saving edit") - return m, m.saveEditedMessage() - } + case ViewModeConfirmRestore: + // Confirm restore mode keys are handled above before textarea.Update() + // to prevent keys from being typed into the chat box } case tea.WindowSizeMsg: @@ -759,22 +969,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.debugLog("messagesLoadedMsg: loaded %d messages", len(msg.messages)) m.llmMessages = msg.messages m.selectedMessageIdx = 0 + + // Find the most recent selectable message (start from end) if len(m.llmMessages) > 0 { - m.selectedMessageIdx = len(m.llmMessages) - 1 // Start at most recent + found := false + for i := len(m.llmMessages) - 1; i >= 0; i-- { + if m.isSelectableMessage(m.llmMessages[i]) { + m.selectedMessageIdx = i + found = true + break + } + } + // If no selectable message found, just set to last message + if !found { + m.selectedMessageIdx = len(m.llmMessages) - 1 + } } m.viewMode = ViewModeMessageList m.textarea.Blur() m.updateMessageListViewport() case messageEditedMsg: - m.debugLog("messageEditedMsg: message edited successfully") - m.viewMode = ViewModeChat - m.textarea.Reset() - m.textarea.Focus() - m.addMessage("Message edited successfully. The thread has been updated.", "system") + m.debugLog("messageEditedMsg: message edited successfully, files_restored=%d, files_deleted=%d", msg.filesRestored, msg.filesDeleted) + // View mode was already switched to Chat when Enter was pressed + // Just display the success message + totalChanged := msg.filesRestored + msg.filesDeleted + if totalChanged > 0 { + parts := []string{} + if msg.filesRestored > 0 { + parts = append(parts, fmt.Sprintf("%d restored", msg.filesRestored)) + } + if msg.filesDeleted > 0 { + parts = append(parts, fmt.Sprintf("%d deleted", msg.filesDeleted)) + } + m.addMessage(fmt.Sprintf("Message edited successfully. Files changed: %s. Waiting for LLM response...", strings.Join(parts, ", ")), "system") + } else { + m.addMessage("Message edited successfully. Waiting for LLM response...", "system") + } m.updateViewport() - // Reload messages to get updated list - return m, m.loadMessages() + // Stream is already open and listening, new response will arrive automatically + // No need to reload messages - just wait for streaming events + + case restoreRequiredMsg: + m.debugLog("restoreRequiredMsg: checkpoint restoration required for message") + // Store confirmation state + m.restoreConfirmMessagePath = msg.messagePath + m.restoreConfirmContent = msg.content + m.restoreConfirmOrigContent = msg.origContent + // Switch to confirmation view + m.viewMode = ViewModeConfirmRestore } return m, tea.Batch(tiCmd, vpCmd) @@ -793,6 +1036,8 @@ func (m Model) View() string { return m.viewMessageList() case ViewModeEditing: return m.viewEditing() + case ViewModeConfirmRestore: + return m.viewConfirmRestore() default: return "Unknown view mode" } @@ -876,7 +1121,61 @@ func (m Model) viewEditing() string { b.WriteString("\n\n") // Help text - help := helpStyle.Render("Ctrl+S: save • Esc: cancel") + help := helpStyle.Render("Enter: save • Alt+Enter: new line • Esc: cancel") + b.WriteString(help) + + return b.String() +} + +// viewConfirmRestore renders the checkpoint restoration confirmation view +func (m Model) viewConfirmRestore() string { + var b strings.Builder + + // Header + title := titleStyle.Render("Checkpoint Restoration Required") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n\n") + + // Warning message + warningStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("220")). + Bold(true) + + warning := warningStyle.Render("⚠ A checkpoint exists for this message") + b.WriteString(warning) + b.WriteString("\n\n") + + // Explanation + explanationStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + explanation := explanationStyle.Render( + "A checkpoint exists for this message. You have three options:\n\n" + + " 1. Restore the checkpoint and save the edit\n" + + " • Restores files that existed when the checkpoint was created\n" + + " • Deletes files that were created after the checkpoint\n" + + " • Overwrites any changes made to existing files since then\n\n" + + " 2. Save the edit WITHOUT restoring the checkpoint\n" + + " • Keeps current file state\n" + + " • Creates a new checkpoint that replaces the old one\n\n" + + " 3. Cancel and return to message list", + ) + b.WriteString(explanation) + b.WriteString("\n\n") + + // Prompt + promptStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")). + Bold(true) + + prompt := promptStyle.Render("What would you like to do?") + b.WriteString(prompt) + b.WriteString("\n\n") + + // Help text with three distinct options + help := helpStyle.Render("Y: restore and save • N: save without restoring • Esc: cancel") b.WriteString(help) return b.String() @@ -972,6 +1271,20 @@ func (m *Model) streamResponse(query string) tea.Cmd { newThreadID = client.ExtractThreadIDFromPath(thread.Path) threadPath = thread.Path m.debugLog("streamResponse: thread created, newThreadID=%s, path=%s", newThreadID, threadPath) + + // Configure working directory for checkpoint creation + workDir, err := os.Getwd() + if err != nil { + m.debugLog("streamResponse: failed to get working directory: %v", err) + } else { + m.debugLog("streamResponse: configuring working directory: %s", workDir) + if err := m.client.ConfigureThreadWorkingDirectory(m.ctx, threadPath, workDir); err != nil { + m.debugLog("streamResponse: failed to configure working directory: %v", err) + // Don't fail the request if working directory configuration fails + } else { + m.debugLog("streamResponse: working directory configured successfully") + } + } } else { m.debugLog("streamResponse: using existing thread, creating user message") // Add user message to existing thread @@ -1154,8 +1467,8 @@ func (m *Model) loadMessages() tea.Cmd { m.debugLog("loadMessages: loading messages for thread %s", m.threadID) threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) - // Fetch all messages (use large page size) - resp, err := m.client.ListLlmMessages(m.ctx, threadPath, 1000, "") + // Fetch all messages (use max page size of 200 per API limit) + resp, err := m.client.ListLlmMessages(m.ctx, threadPath, 200, "") if err != nil { m.debugLog("loadMessages: failed to load messages: %v", err) return errMsg{err: fmt.Errorf("failed to load messages: %w", err)} @@ -1175,19 +1488,33 @@ func (m *Model) updateMessageListViewport() { var b strings.Builder for i, msg := range m.llmMessages { + // Skip tool result messages entirely (don't show them) + if m.isToolResultMessage(msg) { + continue + } + // Format message for display role := "Unknown" + selectable := false switch msg.Role { case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER: role = "User" + selectable = true case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT: role = "Assistant" + selectable = false } - // Get message content preview (first text content) + // Get message content preview contentPreview := "" if len(msg.Contents) > 0 { for _, content := range msg.Contents { + // For tool calls, show a summary instead of content + if toolCall := content.GetToolCall(); toolCall != nil { + contentPreview = fmt.Sprintf("[Tool: %s]", toolCall.Name) + break + } + // For text content, show preview if text := content.GetText(); text != "" { // Truncate long text if len(text) > 100 { @@ -1197,20 +1524,34 @@ func (m *Model) updateMessageListViewport() { } break } + // For thinking content + if thinking := content.GetThinking(); thinking != nil { + contentPreview = "[Thinking]" + break + } } } if contentPreview == "" { - contentPreview = "[No text content]" + contentPreview = "[No content]" } // Format the line + prefix := " " + if !selectable { + prefix = " " // Non-selectable messages get same prefix but different style + } + line := fmt.Sprintf("%d. [%s] %s", msg.Index, role, contentPreview) - // Apply style based on selection - if i == m.selectedMessageIdx { + // Apply style based on selection and selectability + if i == m.selectedMessageIdx && selectable { line = selectedItemStyle.Render("> " + line) + } else if !selectable { + // Use a dimmed style for non-selectable messages + dimmedStyle := messageListItemStyle.Foreground(lipgloss.Color("240")) + line = dimmedStyle.Render(prefix + line) } else { - line = messageListItemStyle.Render(" " + line) + line = messageListItemStyle.Render(prefix + line) } b.WriteString(line) @@ -1220,6 +1561,34 @@ func (m *Model) updateMessageListViewport() { m.messageListViewport.SetContent(b.String()) } +// isToolResultMessage checks if a message contains only tool results +func (m *Model) isToolResultMessage(msg *threadv1alpha1.LlmMessage) bool { + if len(msg.Contents) == 0 { + return false + } + + // Check if all contents are tool results + for _, content := range msg.Contents { + if content.GetToolResult() == nil { + return false + } + } + return true +} + +// isSelectableMessage checks if a message can be selected for editing +func (m *Model) isSelectableMessage(msg *threadv1alpha1.LlmMessage) bool { + // Only user messages are selectable + if msg.Role != threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER { + return false + } + // Skip tool result messages + if m.isToolResultMessage(msg) { + return false + } + return true +} + // startEditingMessage starts editing a message func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { m.debugLog("startEditingMessage: editing message %s", msg.Path) @@ -1253,6 +1622,7 @@ func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { // Set up editing state m.editingMessagePath = msg.Path m.editingMessageOrig = textContent + m.editingMessage = msg // Store full message for checkpoint check m.textarea.SetValue(textContent) m.textarea.Focus() m.viewMode = ViewModeEditing @@ -1260,39 +1630,255 @@ func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { return nil } -// saveEditedMessage saves the edited message -func (m *Model) saveEditedMessage() tea.Cmd { - newContent := strings.TrimSpace(m.textarea.Value()) +// saveEditedMessageAsync saves the edited message asynchronously +func (m *Model) saveEditedMessageAsync(messagePath, origContent, newContent string, restore *bool) tea.Cmd { if newContent == "" { - m.debugLog("saveEditedMessage: empty content, not saving") + m.debugLog("saveEditedMessageAsync: empty content, not saving") return func() tea.Msg { return errMsg{err: fmt.Errorf("message content cannot be empty")} } } - if newContent == m.editingMessageOrig { - m.debugLog("saveEditedMessage: no changes made") - // No changes, just return to message list - m.viewMode = ViewModeMessageList - m.textarea.Reset() - m.textarea.Blur() - return nil + if newContent == origContent { + m.debugLog("saveEditedMessageAsync: no changes made") + // No changes, just show a message + return func() tea.Msg { + return messageEditedMsg{filesRestored: 0, filesDeleted: 0} + } } - messagePath := m.editingMessagePath return func() tea.Msg { - m.debugLog("saveEditedMessage: saving edited message to %s", messagePath) + m.debugLog("saveEditedMessageAsync: saving edited message to %s, restore=%v", messagePath, restore) - // Call the API to edit the message - _, err := m.client.EditThreadMessage(m.ctx, messagePath, newContent, false) + // Edit the message + resp, err := m.client.EditThreadMessage(m.ctx, messagePath, newContent, restore) if err != nil { - m.debugLog("saveEditedMessage: failed to edit message: %v", err) + m.debugLog("saveEditedMessageAsync: failed to edit message: %v", err) return errMsg{err: fmt.Errorf("failed to edit message: %w", err)} } - m.debugLog("saveEditedMessage: message edited successfully") - return messageEditedMsg{} + m.debugLog("saveEditedMessageAsync: message edited successfully, file_restorations=%d", len(resp.FileRestorations)) + + // Restore files if any (checkpoint restoration) + filesRestored := 0 + filesDeleted := 0 + if len(resp.FileRestorations) > 0 { + m.debugLog("saveEditedMessageAsync: restoring %d files from checkpoint", len(resp.FileRestorations)) + + // Get working directory to determine which files to delete + workDir, err := os.Getwd() + if err != nil { + m.debugLog("saveEditedMessageAsync: failed to get working directory: %v", err) + return errMsg{err: fmt.Errorf("failed to get working directory: %w", err)} + } + + // Build set of relative file paths that should exist (from restoration) + // Use relative paths for comparison to avoid issues with path representation + restoredFiles := make(map[string]bool) + for _, restoration := range resp.FileRestorations { + // Safety check: never restore VCS directories (in case old checkpoints captured them) + if m.isVCSPath(restoration.Path) { + m.debugLog("saveEditedMessageAsync: skipping VCS file from checkpoint: %s", restoration.Path) + continue + } + + m.debugLog("saveEditedMessageAsync: restoring file %s (%d bytes)", restoration.Path, len(restoration.Content)) + + // Check if file exists and compare content to see if it actually changed + fileChanged := false + existingContent, err := os.ReadFile(restoration.Path) + if err != nil { + // File doesn't exist, so this is a new restoration + fileChanged = true + } else { + // File exists, compare content + fileChanged = !bytes.Equal(existingContent, restoration.Content) + } + + // Ensure directory exists + dir := filepath.Dir(restoration.Path) + if err := os.MkdirAll(dir, 0755); err != nil { + m.debugLog("saveEditedMessageAsync: failed to create directory %s: %v", dir, err) + return errMsg{err: fmt.Errorf("failed to create directory %s: %w", dir, err)} + } + + if err := os.WriteFile(restoration.Path, restoration.Content, 0644); err != nil { + m.debugLog("saveEditedMessageAsync: failed to restore file %s: %v", restoration.Path, err) + return errMsg{err: fmt.Errorf("failed to restore file %s: %w", restoration.Path, err)} + } + + if fileChanged { + filesRestored++ + m.debugLog("saveEditedMessageAsync: file changed during restoration: %s", restoration.Path) + } + + // Store relative path for comparison + relPath, err := filepath.Rel(workDir, restoration.Path) + if err != nil { + m.debugLog("saveEditedMessageAsync: failed to get relative path for %s: %v", restoration.Path, err) + // Fall back to using the full path + relPath = restoration.Path + } + restoredFiles[relPath] = true + m.debugLog("saveEditedMessageAsync: tracking restored file: %s", relPath) + } + m.debugLog("saveEditedMessageAsync: all files restored successfully from checkpoint") + + // Delete files that weren't in the checkpoint + // These are "orphaned" files created after the checkpoint + deletedCount, err := m.deleteOrphanedFiles(workDir, restoredFiles) + if err != nil { + m.debugLog("saveEditedMessageAsync: failed to delete orphaned files: %v", err) + // Don't fail the entire operation if cleanup fails + // Just log the error and continue + } else { + filesDeleted = deletedCount + } + } + + return messageEditedMsg{filesRestored: filesRestored, filesDeleted: filesDeleted} + } +} + +// deleteOrphanedFiles deletes files in the working directory that aren't in the restored set +// This removes files that were created after the checkpoint being restored +// restoredFiles should contain relative paths (relative to workDir) +// Returns the number of files successfully deleted +func (m *Model) deleteOrphanedFiles(workDir string, restoredFiles map[string]bool) (int, error) { + m.debugLog("deleteOrphanedFiles: scanning %s", workDir) + m.debugLog("deleteOrphanedFiles: restored files set contains %d entries", len(restoredFiles)) + + // Load .gitignore patterns to respect them during cleanup + gitignorePatterns := m.loadGitignorePatterns(workDir) + m.debugLog("deleteOrphanedFiles: loaded %d gitignore patterns", len(gitignorePatterns)) + + var filesToDelete []string + + // Walk the directory to find files that aren't in the restored set + err := filepath.WalkDir(workDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + m.debugLog("deleteOrphanedFiles: error walking %s: %v", path, err) + return nil // Continue walking + } + + // Skip the working directory itself + if path == workDir { + return nil + } + + // Get relative path for comparison + relPath, err := filepath.Rel(workDir, path) + if err != nil { + m.debugLog("deleteOrphanedFiles: failed to get relative path for %s: %v", path, err) + return nil + } + + // Check if should be ignored by .gitignore + if m.shouldIgnoreFile(relPath, gitignorePatterns, d.IsDir()) { + if d.IsDir() { + m.debugLog("deleteOrphanedFiles: skipping ignored directory: %s", relPath) + return fs.SkipDir + } + m.debugLog("deleteOrphanedFiles: skipping ignored file: %s", relPath) + return nil + } + + // Skip directories (we only delete files) + if d.IsDir() { + return nil + } + + // Check if this file was restored (using relative path) + if !restoredFiles[relPath] { + m.debugLog("deleteOrphanedFiles: found orphaned file: %s", relPath) + filesToDelete = append(filesToDelete, path) + } + + return nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to walk directory: %w", err) + } + + // Delete the orphaned files + m.debugLog("deleteOrphanedFiles: found %d orphaned files to delete", len(filesToDelete)) + deletedCount := 0 + for _, path := range filesToDelete { + relPath, _ := filepath.Rel(workDir, path) + m.debugLog("deleteOrphanedFiles: deleting %s", relPath) + if err := os.Remove(path); err != nil { + m.debugLog("deleteOrphanedFiles: failed to delete %s: %v", relPath, err) + // Continue with other files even if one fails + } else { + deletedCount++ + } + } + + m.debugLog("deleteOrphanedFiles: cleanup complete, deleted %d files", deletedCount) + return deletedCount, nil +} + +// loadGitignorePatterns loads patterns from .gitignore file +func (m *Model) loadGitignorePatterns(workDir string) []string { + gitignorePath := filepath.Join(workDir, ".gitignore") + file, err := os.Open(gitignorePath) + if err != nil { + return nil + } + defer file.Close() + + var patterns []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, line) + } + + return patterns +} + +// isVCSPath checks if a path is within a version control system directory +func (m *Model) isVCSPath(path string) bool { + vcsDirectories := []string{".git", ".svn", ".hg", ".bzr"} + + // Normalize path separators + normalizedPath := filepath.ToSlash(path) + + for _, vcs := range vcsDirectories { + // Check if path contains VCS directory + if strings.Contains(normalizedPath, "/"+vcs+"/") || + strings.HasPrefix(normalizedPath, vcs+"/") || + strings.HasSuffix(normalizedPath, "/"+vcs) { + return true + } + } + return false +} + +// shouldIgnoreFile checks if a path should be ignored based on .gitignore patterns +func (m *Model) shouldIgnoreFile(path string, patterns []string, isDir bool) bool { + // Always ignore VCS directories + if m.isVCSPath(path) { + return true + } + + for _, pattern := range patterns { + // Simple pattern matching (not full gitignore spec, but good enough) + matched, err := filepath.Match(pattern, filepath.Base(path)) + if err == nil && matched { + return true + } + + // Also check if path contains the pattern (for patterns like node_modules) + if strings.Contains(path, pattern) { + return true + } } + return false } // executeLocalTool executes an environment tool locally and submits the result diff --git a/tim-db/gen/db/checkpoint.sql.go b/tim-db/gen/db/checkpoint.sql.go index 2481bc5fa..d56bf3c4f 100644 --- a/tim-db/gen/db/checkpoint.sql.go +++ b/tim-db/gen/db/checkpoint.sql.go @@ -398,6 +398,48 @@ func (q *Queries) GetThreadWorkingDirectory(ctx context.Context, threadUid uuid. return working_directory, err } +const listAllFilePathsAtCheckpoint = `-- name: ListAllFilePathsAtCheckpoint :many +SELECT DISTINCT cfs.file_path +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = $1::uuid + AND tc.create_time <= ( + SELECT create_time + FROM thread_checkpoint + WHERE uid = $2::uuid + ) + AND cfs.is_deleted = false +ORDER BY cfs.file_path ASC +` + +type ListAllFilePathsAtCheckpointParams struct { + ThreadUID uuid.UUID + CheckpointUid uuid.UUID +} + +// Get all unique file paths that existed at or before a checkpoint +// This includes files from previous checkpoints that haven't changed +// Used for complete checkpoint restoration +func (q *Queries) ListAllFilePathsAtCheckpoint(ctx context.Context, arg ListAllFilePathsAtCheckpointParams) ([]string, error) { + rows, err := q.db.Query(ctx, listAllFilePathsAtCheckpoint, arg.ThreadUID, arg.CheckpointUid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var file_path string + if err := rows.Scan(&file_path); err != nil { + return nil, err + } + items = append(items, file_path) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listCheckpointsByThread = `-- name: ListCheckpointsByThread :many SELECT uid, thread_uid, message_uid, working_directory, total_files, total_size_bytes, create_time FROM thread_checkpoint diff --git a/tim-db/queries/checkpoint.sql b/tim-db/queries/checkpoint.sql index 7f13a1500..4a7f83bbb 100644 --- a/tim-db/queries/checkpoint.sql +++ b/tim-db/queries/checkpoint.sql @@ -155,3 +155,19 @@ SELECT working_directory FROM thread WHERE uid = sqlc.arg(thread_uid)::uuid; +-- name: ListAllFilePathsAtCheckpoint :many +-- Get all unique file paths that existed at or before a checkpoint +-- This includes files from previous checkpoints that haven't changed +-- Used for complete checkpoint restoration +SELECT DISTINCT cfs.file_path +FROM checkpoint_file_snapshot cfs +INNER JOIN thread_checkpoint tc ON cfs.checkpoint_uid = tc.uid +WHERE tc.thread_uid = sqlc.arg(thread_uid)::uuid + AND tc.create_time <= ( + SELECT create_time + FROM thread_checkpoint + WHERE uid = sqlc.arg(checkpoint_uid)::uuid + ) + AND cfs.is_deleted = false +ORDER BY cfs.file_path ASC; + diff --git a/tim-proto/gen/openapi.yaml b/tim-proto/gen/openapi.yaml index f3a54462d..91849fd31 100644 --- a/tim-proto/gen/openapi.yaml +++ b/tim-proto/gen/openapi.yaml @@ -1630,7 +1630,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/LlmMessage' + $ref: '#/components/schemas/EditThreadMessageResponse' default: description: Default error response content: @@ -2616,13 +2616,31 @@ components: content: type: string description: The new content for the message - confirmRestore: + restore: type: boolean description: |- - User confirmed checkpoint restoration (required if checkpoint exists with files) - If not provided and checkpoint restoration is needed, an error will be returned - with details about what will be restored. + Whether to restore files from checkpoint (if one exists). + If not set: returns error when checkpoint exists (to prompt user). + If true: restores files from checkpoint and returns file restoration data. + If false: saves without restoring (new checkpoint replaces old one). description: EditThreadMessageRequest is used to edit a message (e.g., edit user message content) + EditThreadMessageResponse: + required: + - message + type: object + properties: + message: + allOf: + - $ref: '#/components/schemas/LlmMessage' + description: The updated message + fileRestorations: + type: array + items: + $ref: '#/components/schemas/FileRestoration' + description: |- + Files to restore from checkpoint (if applicable) + The client should restore these files before continuing + description: EditThreadMessageResponse contains the edited message and any file restoration data ExchangeAccessTokenRequest: required: - accessToken @@ -2663,6 +2681,23 @@ components: type: string description: The member session ID (critical for session operations) description: ExchangeAccessTokenResponse contains the session JWT and related information + FileRestoration: + required: + - path + - content + type: object + properties: + path: + type: string + description: The absolute path to the file + content: + type: string + description: The content to restore + format: bytes + description: |- + FileRestoration contains information about a file to restore from a checkpoint. + This is a data transfer object, not an API resource. + buf:lint:ignore AEP_0004_RESOURCE_ANNOTATION FinalizePersonaRevisionRequest: required: - path @@ -2979,6 +3014,10 @@ components: allOf: - $ref: '#/components/schemas/TokenUsage' description: Token usage for this message (optional, set after LLM response) + hasCheckpoint: + readOnly: true + type: boolean + description: Whether a checkpoint exists for this message description: LlmMessages are messages that make up a thread. LlmMessageContent: type: object diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go index 034e341fc..81dd500c3 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go +++ b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.pb.go @@ -910,12 +910,13 @@ type EditThreadMessageRequest struct { Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // The new content for the message Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - // User confirmed checkpoint restoration (required if checkpoint exists with files) - // If not provided and checkpoint restoration is needed, an error will be returned - // with details about what will be restored. - ConfirmRestore bool `protobuf:"varint,3,opt,name=confirm_restore,json=confirmRestore,proto3" json:"confirm_restore,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether to restore files from checkpoint (if one exists). + // If not set: returns error when checkpoint exists (to prompt user). + // If true: restores files from checkpoint and returns file restoration data. + // If false: saves without restoring (new checkpoint replaces old one). + Restore *bool `protobuf:"varint,3,opt,name=restore,proto3,oneof" json:"restore,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EditThreadMessageRequest) Reset() { @@ -962,13 +963,69 @@ func (x *EditThreadMessageRequest) GetContent() string { return "" } -func (x *EditThreadMessageRequest) GetConfirmRestore() bool { - if x != nil { - return x.ConfirmRestore +func (x *EditThreadMessageRequest) GetRestore() bool { + if x != nil && x.Restore != nil { + return *x.Restore } return false } +// EditThreadMessageResponse contains the edited message and any file restoration data +type EditThreadMessageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The updated message + Message *LlmMessage `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // Files to restore from checkpoint (if applicable) + // The client should restore these files before continuing + FileRestorations []*FileRestoration `protobuf:"bytes,2,rep,name=file_restorations,json=fileRestorations,proto3" json:"file_restorations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditThreadMessageResponse) Reset() { + *x = EditThreadMessageResponse{} + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditThreadMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditThreadMessageResponse) ProtoMessage() {} + +func (x *EditThreadMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditThreadMessageResponse.ProtoReflect.Descriptor instead. +func (*EditThreadMessageResponse) Descriptor() ([]byte, []int) { + return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{15} +} + +func (x *EditThreadMessageResponse) GetMessage() *LlmMessage { + if x != nil { + return x.Message + } + return nil +} + +func (x *EditThreadMessageResponse) GetFileRestorations() []*FileRestoration { + if x != nil { + return x.FileRestorations + } + return nil +} + // ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation type ConfigureThreadWorkingDirectoryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -982,7 +1039,7 @@ type ConfigureThreadWorkingDirectoryRequest struct { func (x *ConfigureThreadWorkingDirectoryRequest) Reset() { *x = ConfigureThreadWorkingDirectoryRequest{} - mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -994,7 +1051,7 @@ func (x *ConfigureThreadWorkingDirectoryRequest) String() string { func (*ConfigureThreadWorkingDirectoryRequest) ProtoMessage() {} func (x *ConfigureThreadWorkingDirectoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15] + mi := &file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1007,7 +1064,7 @@ func (x *ConfigureThreadWorkingDirectoryRequest) ProtoReflect() protoreflect.Mes // Deprecated: Use ConfigureThreadWorkingDirectoryRequest.ProtoReflect.Descriptor instead. func (*ConfigureThreadWorkingDirectoryRequest) Descriptor() ([]byte, []int) { - return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{15} + return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{16} } func (x *ConfigureThreadWorkingDirectoryRequest) GetPath() string { @@ -1085,15 +1142,20 @@ const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\x06parent\x18\x01 \x01(\tBq\xe0A\x02\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/threadR\x06parent\x12R\n" + "\fuser_message\x18\x02 \x01(\v2$.tim.api.thread.v1alpha1.UserMessageB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\vuserMessage\"1\n" + "\vUserMessage\x12\"\n" + - "\x04text\x18\x01 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\x04text\"\x95\x02\n" + + "\x04text\x18\x01 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\x04text\"\x97\x02\n" + "\x18EditThreadMessageRequest\x12\xa5\x01\n" + "\x04path\x18\x01 \x01(\tB\x90\x01\xe0A\x02\xbaHerc2a^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}/messages/[a-fA-F0-9-]{36}$\u0091\x05!\x12\x1ftim.settlerlabs.com/llm-messageR\x04path\x12(\n" + - "\acontent\x18\x02 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\acontent\x12'\n" + - "\x0fconfirm_restore\x18\x03 \x01(\bR\x0econfirmRestore\"\xe9\x01\n" + + "\acontent\x18\x02 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\acontent\x12\x1d\n" + + "\arestore\x18\x03 \x01(\bH\x00R\arestore\x88\x01\x01B\n" + + "\n" + + "\b_restore\"\xbc\x01\n" + + "\x19EditThreadMessageResponse\x12H\n" + + "\amessage\x18\x01 \x01(\v2#.tim.api.thread.v1alpha1.LlmMessageB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\amessage\x12U\n" + + "\x11file_restorations\x18\x02 \x03(\v2(.tim.api.thread.v1alpha1.FileRestorationR\x10fileRestorations\"\xe9\x01\n" + "&ConfigureThreadWorkingDirectoryRequest\x12\x85\x01\n" + "\x04path\x18\x01 \x01(\tBq\xe0A\x02\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/threadR\x04path\x127\n" + "\x11working_directory\x18\x02 \x01(\tB\n" + - "\xbaH\a\xc8\x01\x01r\x02\x10\x01R\x10workingDirectory2\x99\x10\n" + + "\xbaH\a\xc8\x01\x01r\x02\x10\x01R\x10workingDirectory2\xa8\x10\n" + "\rThreadService\x12\x91\x01\n" + "\tGetThread\x12).tim.api.thread.v1alpha1.GetThreadRequest\x1a\x1f.tim.api.thread.v1alpha1.Thread\"8\xdaA\x04path\x82\xd3\xe4\x93\x02+\x12)/v1alpha1/{path=orgs/*/users/*/threads/*}\x12\xa4\x01\n" + "\vListThreads\x12+.tim.api.thread.v1alpha1.ListThreadsRequest\x1a,.tim.api.thread.v1alpha1.ListThreadsResponse\":\xdaA\x06parent\x82\xd3\xe4\x93\x02+\x12)/v1alpha1/{parent=orgs/*/users/*}/threads\x12\xb8\x01\n" + @@ -1104,8 +1166,8 @@ const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\rGetLlmMessage\x12-.tim.api.thread.v1alpha1.GetLlmMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"C\xdaA\x04path\x82\xd3\xe4\x93\x026\x124/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}\x12\xbb\x01\n" + "\x0fListLlmMessages\x12/.tim.api.thread.v1alpha1.ListLlmMessagesRequest\x1a0.tim.api.thread.v1alpha1.ListLlmMessagesResponse\"E\xdaA\x06parent\x82\xd3\xe4\x93\x026\x124/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages\x12\xc4\x01\n" + "\x12StreamThreadEvents\x122.tim.api.thread.v1alpha1.StreamThreadEventsRequest\x1a3.tim.api.thread.v1alpha1.StreamThreadEventsResponse\"C\xdaA\x06parent\x82\xd3\xe4\x93\x024\x122/v1alpha1/{parent=orgs/*/users/*/threads/*}:stream0\x01\x12\xdf\x01\n" + - "\x11SubmitUserMessage\x121.tim.api.thread.v1alpha1.SubmitUserMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"r\xdaA\x13parent,user_message\x82\xd3\xe4\x93\x02V:\fuser_message\"F/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages:submitUserMessage\x12\xc0\x01\n" + - "\x11EditThreadMessage\x121.tim.api.thread.v1alpha1.EditThreadMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"S\xdaA\fpath,content\x82\xd3\xe4\x93\x02>:\x01*\"9/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}:edit\x12\xe3\x01\n" + + "\x11SubmitUserMessage\x121.tim.api.thread.v1alpha1.SubmitUserMessageRequest\x1a#.tim.api.thread.v1alpha1.LlmMessage\"r\xdaA\x13parent,user_message\x82\xd3\xe4\x93\x02V:\fuser_message\"F/v1alpha1/{parent=orgs/*/users/*/threads/*}/messages:submitUserMessage\x12\xcf\x01\n" + + "\x11EditThreadMessage\x121.tim.api.thread.v1alpha1.EditThreadMessageRequest\x1a2.tim.api.thread.v1alpha1.EditThreadMessageResponse\"S\xdaA\fpath,content\x82\xd3\xe4\x93\x02>:\x01*\"9/v1alpha1/{path=orgs/*/users/*/threads/*/messages/*}:edit\x12\xe3\x01\n" + "\x1fConfigureThreadWorkingDirectory\x12?.tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest\x1a\x16.google.protobuf.Empty\"g\xdaA\x16path,working_directory\x82\xd3\xe4\x93\x02H:\x01*\"C/v1alpha1/{path=orgs/*/users/*/threads/*}:configureWorkingDirectoryB\x82\x02\n" + "\x1bcom.tim.api.thread.v1alpha1B\x12ThreadServiceProtoP\x01ZPgithub.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1;threadv1alpha1\xa2\x02\x03TAT\xaa\x02\x17Tim.Api.Thread.V1alpha1\xca\x02\x17Tim\\Api\\Thread\\V1alpha1\xe2\x02#Tim\\Api\\Thread\\V1alpha1\\GPBMetadata\xea\x02\x1aTim::Api::Thread::V1alpha1b\x06proto3" @@ -1121,7 +1183,7 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP() []byte { return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescData } -var file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_tim_api_thread_v1alpha1_thread_service_proto_goTypes = []any{ (*GetThreadRequest)(nil), // 0: tim.api.thread.v1alpha1.GetThreadRequest (*ListThreadsRequest)(nil), // 1: tim.api.thread.v1alpha1.ListThreadsRequest @@ -1138,59 +1200,63 @@ var file_tim_api_thread_v1alpha1_thread_service_proto_goTypes = []any{ (*SubmitUserMessageRequest)(nil), // 12: tim.api.thread.v1alpha1.SubmitUserMessageRequest (*UserMessage)(nil), // 13: tim.api.thread.v1alpha1.UserMessage (*EditThreadMessageRequest)(nil), // 14: tim.api.thread.v1alpha1.EditThreadMessageRequest - (*ConfigureThreadWorkingDirectoryRequest)(nil), // 15: tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest - (*Thread)(nil), // 16: tim.api.thread.v1alpha1.Thread - (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp - (*LlmMessage)(nil), // 18: tim.api.thread.v1alpha1.LlmMessage - (*ContentStartEvent)(nil), // 19: tim.api.thread.v1alpha1.ContentStartEvent - (*ContentDeltaEvent)(nil), // 20: tim.api.thread.v1alpha1.ContentDeltaEvent - (*ContentStopEvent)(nil), // 21: tim.api.thread.v1alpha1.ContentStopEvent - (*v1alpha1.ToolCall)(nil), // 22: tim.api.tool.v1alpha1.ToolCall - (*ThreadStateChangeEvent)(nil), // 23: tim.api.thread.v1alpha1.ThreadStateChangeEvent - (*StreamErrorEvent)(nil), // 24: tim.api.thread.v1alpha1.StreamErrorEvent - (*emptypb.Empty)(nil), // 25: google.protobuf.Empty + (*EditThreadMessageResponse)(nil), // 15: tim.api.thread.v1alpha1.EditThreadMessageResponse + (*ConfigureThreadWorkingDirectoryRequest)(nil), // 16: tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest + (*Thread)(nil), // 17: tim.api.thread.v1alpha1.Thread + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*LlmMessage)(nil), // 19: tim.api.thread.v1alpha1.LlmMessage + (*ContentStartEvent)(nil), // 20: tim.api.thread.v1alpha1.ContentStartEvent + (*ContentDeltaEvent)(nil), // 21: tim.api.thread.v1alpha1.ContentDeltaEvent + (*ContentStopEvent)(nil), // 22: tim.api.thread.v1alpha1.ContentStopEvent + (*v1alpha1.ToolCall)(nil), // 23: tim.api.tool.v1alpha1.ToolCall + (*ThreadStateChangeEvent)(nil), // 24: tim.api.thread.v1alpha1.ThreadStateChangeEvent + (*StreamErrorEvent)(nil), // 25: tim.api.thread.v1alpha1.StreamErrorEvent + (*FileRestoration)(nil), // 26: tim.api.thread.v1alpha1.FileRestoration + (*emptypb.Empty)(nil), // 27: google.protobuf.Empty } var file_tim_api_thread_v1alpha1_thread_service_proto_depIdxs = []int32{ - 16, // 0: tim.api.thread.v1alpha1.ListThreadsResponse.results:type_name -> tim.api.thread.v1alpha1.Thread - 16, // 1: tim.api.thread.v1alpha1.CreateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread - 17, // 2: tim.api.thread.v1alpha1.ForkThreadRequest.before_time:type_name -> google.protobuf.Timestamp - 16, // 3: tim.api.thread.v1alpha1.ForkThreadResponse.thread:type_name -> tim.api.thread.v1alpha1.Thread - 16, // 4: tim.api.thread.v1alpha1.UpdateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread - 18, // 5: tim.api.thread.v1alpha1.ListLlmMessagesResponse.results:type_name -> tim.api.thread.v1alpha1.LlmMessage - 19, // 6: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_start:type_name -> tim.api.thread.v1alpha1.ContentStartEvent - 20, // 7: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_delta:type_name -> tim.api.thread.v1alpha1.ContentDeltaEvent - 21, // 8: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_stop:type_name -> tim.api.thread.v1alpha1.ContentStopEvent - 22, // 9: tim.api.thread.v1alpha1.StreamThreadEventsResponse.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall - 23, // 10: tim.api.thread.v1alpha1.StreamThreadEventsResponse.thread_state_change:type_name -> tim.api.thread.v1alpha1.ThreadStateChangeEvent - 24, // 11: tim.api.thread.v1alpha1.StreamThreadEventsResponse.stream_error:type_name -> tim.api.thread.v1alpha1.StreamErrorEvent + 17, // 0: tim.api.thread.v1alpha1.ListThreadsResponse.results:type_name -> tim.api.thread.v1alpha1.Thread + 17, // 1: tim.api.thread.v1alpha1.CreateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread + 18, // 2: tim.api.thread.v1alpha1.ForkThreadRequest.before_time:type_name -> google.protobuf.Timestamp + 17, // 3: tim.api.thread.v1alpha1.ForkThreadResponse.thread:type_name -> tim.api.thread.v1alpha1.Thread + 17, // 4: tim.api.thread.v1alpha1.UpdateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread + 19, // 5: tim.api.thread.v1alpha1.ListLlmMessagesResponse.results:type_name -> tim.api.thread.v1alpha1.LlmMessage + 20, // 6: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_start:type_name -> tim.api.thread.v1alpha1.ContentStartEvent + 21, // 7: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_delta:type_name -> tim.api.thread.v1alpha1.ContentDeltaEvent + 22, // 8: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_stop:type_name -> tim.api.thread.v1alpha1.ContentStopEvent + 23, // 9: tim.api.thread.v1alpha1.StreamThreadEventsResponse.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall + 24, // 10: tim.api.thread.v1alpha1.StreamThreadEventsResponse.thread_state_change:type_name -> tim.api.thread.v1alpha1.ThreadStateChangeEvent + 25, // 11: tim.api.thread.v1alpha1.StreamThreadEventsResponse.stream_error:type_name -> tim.api.thread.v1alpha1.StreamErrorEvent 13, // 12: tim.api.thread.v1alpha1.SubmitUserMessageRequest.user_message:type_name -> tim.api.thread.v1alpha1.UserMessage - 0, // 13: tim.api.thread.v1alpha1.ThreadService.GetThread:input_type -> tim.api.thread.v1alpha1.GetThreadRequest - 1, // 14: tim.api.thread.v1alpha1.ThreadService.ListThreads:input_type -> tim.api.thread.v1alpha1.ListThreadsRequest - 3, // 15: tim.api.thread.v1alpha1.ThreadService.CreateThread:input_type -> tim.api.thread.v1alpha1.CreateThreadRequest - 4, // 16: tim.api.thread.v1alpha1.ThreadService.ForkThread:input_type -> tim.api.thread.v1alpha1.ForkThreadRequest - 6, // 17: tim.api.thread.v1alpha1.ThreadService.UpdateThread:input_type -> tim.api.thread.v1alpha1.UpdateThreadRequest - 7, // 18: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:input_type -> tim.api.thread.v1alpha1.GetLlmMessageRequest - 8, // 19: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:input_type -> tim.api.thread.v1alpha1.ListLlmMessagesRequest - 10, // 20: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:input_type -> tim.api.thread.v1alpha1.StreamThreadEventsRequest - 12, // 21: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:input_type -> tim.api.thread.v1alpha1.SubmitUserMessageRequest - 14, // 22: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:input_type -> tim.api.thread.v1alpha1.EditThreadMessageRequest - 15, // 23: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:input_type -> tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest - 16, // 24: tim.api.thread.v1alpha1.ThreadService.GetThread:output_type -> tim.api.thread.v1alpha1.Thread - 2, // 25: tim.api.thread.v1alpha1.ThreadService.ListThreads:output_type -> tim.api.thread.v1alpha1.ListThreadsResponse - 16, // 26: tim.api.thread.v1alpha1.ThreadService.CreateThread:output_type -> tim.api.thread.v1alpha1.Thread - 5, // 27: tim.api.thread.v1alpha1.ThreadService.ForkThread:output_type -> tim.api.thread.v1alpha1.ForkThreadResponse - 16, // 28: tim.api.thread.v1alpha1.ThreadService.UpdateThread:output_type -> tim.api.thread.v1alpha1.Thread - 18, // 29: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage - 9, // 30: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:output_type -> tim.api.thread.v1alpha1.ListLlmMessagesResponse - 11, // 31: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:output_type -> tim.api.thread.v1alpha1.StreamThreadEventsResponse - 18, // 32: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage - 18, // 33: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage - 25, // 34: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:output_type -> google.protobuf.Empty - 24, // [24:35] is the sub-list for method output_type - 13, // [13:24] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 19, // 13: tim.api.thread.v1alpha1.EditThreadMessageResponse.message:type_name -> tim.api.thread.v1alpha1.LlmMessage + 26, // 14: tim.api.thread.v1alpha1.EditThreadMessageResponse.file_restorations:type_name -> tim.api.thread.v1alpha1.FileRestoration + 0, // 15: tim.api.thread.v1alpha1.ThreadService.GetThread:input_type -> tim.api.thread.v1alpha1.GetThreadRequest + 1, // 16: tim.api.thread.v1alpha1.ThreadService.ListThreads:input_type -> tim.api.thread.v1alpha1.ListThreadsRequest + 3, // 17: tim.api.thread.v1alpha1.ThreadService.CreateThread:input_type -> tim.api.thread.v1alpha1.CreateThreadRequest + 4, // 18: tim.api.thread.v1alpha1.ThreadService.ForkThread:input_type -> tim.api.thread.v1alpha1.ForkThreadRequest + 6, // 19: tim.api.thread.v1alpha1.ThreadService.UpdateThread:input_type -> tim.api.thread.v1alpha1.UpdateThreadRequest + 7, // 20: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:input_type -> tim.api.thread.v1alpha1.GetLlmMessageRequest + 8, // 21: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:input_type -> tim.api.thread.v1alpha1.ListLlmMessagesRequest + 10, // 22: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:input_type -> tim.api.thread.v1alpha1.StreamThreadEventsRequest + 12, // 23: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:input_type -> tim.api.thread.v1alpha1.SubmitUserMessageRequest + 14, // 24: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:input_type -> tim.api.thread.v1alpha1.EditThreadMessageRequest + 16, // 25: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:input_type -> tim.api.thread.v1alpha1.ConfigureThreadWorkingDirectoryRequest + 17, // 26: tim.api.thread.v1alpha1.ThreadService.GetThread:output_type -> tim.api.thread.v1alpha1.Thread + 2, // 27: tim.api.thread.v1alpha1.ThreadService.ListThreads:output_type -> tim.api.thread.v1alpha1.ListThreadsResponse + 17, // 28: tim.api.thread.v1alpha1.ThreadService.CreateThread:output_type -> tim.api.thread.v1alpha1.Thread + 5, // 29: tim.api.thread.v1alpha1.ThreadService.ForkThread:output_type -> tim.api.thread.v1alpha1.ForkThreadResponse + 17, // 30: tim.api.thread.v1alpha1.ThreadService.UpdateThread:output_type -> tim.api.thread.v1alpha1.Thread + 19, // 31: tim.api.thread.v1alpha1.ThreadService.GetLlmMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage + 9, // 32: tim.api.thread.v1alpha1.ThreadService.ListLlmMessages:output_type -> tim.api.thread.v1alpha1.ListLlmMessagesResponse + 11, // 33: tim.api.thread.v1alpha1.ThreadService.StreamThreadEvents:output_type -> tim.api.thread.v1alpha1.StreamThreadEventsResponse + 19, // 34: tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage:output_type -> tim.api.thread.v1alpha1.LlmMessage + 15, // 35: tim.api.thread.v1alpha1.ThreadService.EditThreadMessage:output_type -> tim.api.thread.v1alpha1.EditThreadMessageResponse + 27, // 36: tim.api.thread.v1alpha1.ThreadService.ConfigureThreadWorkingDirectory:output_type -> google.protobuf.Empty + 26, // [26:37] is the sub-list for method output_type + 15, // [15:26] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_tim_api_thread_v1alpha1_thread_service_proto_init() } @@ -1207,13 +1273,14 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_init() { (*StreamThreadEventsResponse_ThreadStateChange)(nil), (*StreamThreadEventsResponse_StreamError)(nil), } + file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[14].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc), len(file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 16, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json index ebf534a5e..071b0b6d0 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json +++ b/tim-proto/gen/tim/api/thread/v1alpha1/thread_service.swagger.json @@ -432,7 +432,7 @@ "200": { "description": "A successful response.", "schema": { - "$ref": "#/definitions/v1alpha1LlmMessage" + "$ref": "#/definitions/v1alpha1EditThreadMessageResponse" } }, "default": { @@ -516,9 +516,9 @@ "type": "string", "title": "The new content for the message" }, - "confirmRestore": { + "restore": { "type": "boolean", - "description": "User confirmed checkpoint restoration (required if checkpoint exists with files)\nIf not provided and checkpoint restoration is needed, an error will be returned\nwith details about what will be restored." + "description": "Whether to restore files from checkpoint (if one exists).\nIf not set: returns error when checkpoint exists (to prompt user).\nIf true: restores files from checkpoint and returns file restoration data.\nIf false: saves without restoring (new checkpoint replaces old one)." } }, "title": "EditThreadMessageRequest is used to edit a message (e.g., edit user message content)" @@ -650,6 +650,46 @@ "description": "- CONTENT_TYPE_UNSPECIFIED: Default unspecified\n - CONTENT_TYPE_TEXT: Text content\n - CONTENT_TYPE_THINKING: Thinking content", "title": "ContentType enum for text and thinking content" }, + "v1alpha1EditThreadMessageResponse": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/v1alpha1LlmMessage", + "title": "The updated message" + }, + "fileRestorations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1alpha1FileRestoration" + }, + "title": "Files to restore from checkpoint (if applicable)\nThe client should restore these files before continuing" + } + }, + "title": "EditThreadMessageResponse contains the edited message and any file restoration data", + "required": [ + "message" + ] + }, + "v1alpha1FileRestoration": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "The absolute path to the file" + }, + "content": { + "type": "string", + "format": "byte", + "title": "The content to restore" + } + }, + "title": "FileRestoration contains information about a file to restore from a checkpoint.\nThis is a data transfer object, not an API resource.\nbuf:lint:ignore AEP_0004_RESOURCE_ANNOTATION", + "required": [ + "path", + "content" + ] + }, "v1alpha1ForkThreadResponse": { "type": "object", "properties": { @@ -763,6 +803,11 @@ "$ref": "#/definitions/v1alpha1TokenUsage", "title": "Token usage for this message (optional, set after LLM response)", "readOnly": true + }, + "hasCheckpoint": { + "type": "boolean", + "title": "Whether a checkpoint exists for this message", + "readOnly": true } }, "description": "LlmMessages are messages that make up a thread." diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/thread_types.pb.go b/tim-proto/gen/tim/api/thread/v1alpha1/thread_types.pb.go index c8106cdaf..581093c15 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/thread_types.pb.go +++ b/tim-proto/gen/tim/api/thread/v1alpha1/thread_types.pb.go @@ -323,7 +323,9 @@ type LlmMessage struct { // System set at creation time CreateTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` // Token usage for this message (optional, set after LLM response) - TokenUsage *v1alpha1.TokenUsage `protobuf:"bytes,10,opt,name=token_usage,json=tokenUsage,proto3" json:"token_usage,omitempty"` + TokenUsage *v1alpha1.TokenUsage `protobuf:"bytes,10,opt,name=token_usage,json=tokenUsage,proto3" json:"token_usage,omitempty"` + // Whether a checkpoint exists for this message + HasCheckpoint bool `protobuf:"varint,11,opt,name=has_checkpoint,json=hasCheckpoint,proto3" json:"has_checkpoint,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -428,6 +430,13 @@ func (x *LlmMessage) GetTokenUsage() *v1alpha1.TokenUsage { return nil } +func (x *LlmMessage) GetHasCheckpoint() bool { + if x != nil { + return x.HasCheckpoint + } + return false +} + // LlmMessageContent is a content block of an LLM message. // Thinking content with signature for verification type Thinking struct { @@ -887,6 +896,63 @@ func (x *StreamErrorEvent) GetError() string { return "" } +// FileRestoration contains information about a file to restore from a checkpoint. +// This is a data transfer object, not an API resource. +// buf:lint:ignore AEP_0004_RESOURCE_ANNOTATION +type FileRestoration struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The absolute path to the file + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // The content to restore + Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileRestoration) Reset() { + *x = FileRestoration{} + mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileRestoration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileRestoration) ProtoMessage() {} + +func (x *FileRestoration) ProtoReflect() protoreflect.Message { + mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileRestoration.ProtoReflect.Descriptor instead. +func (*FileRestoration) Descriptor() ([]byte, []int) { + return file_tim_api_thread_v1alpha1_thread_types_proto_rawDescGZIP(), []int{9} +} + +func (x *FileRestoration) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileRestoration) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + // The identifier for the original thread this thread was forked from. type Thread_ParentThreadId struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -902,7 +968,7 @@ type Thread_ParentThreadId struct { func (x *Thread_ParentThreadId) Reset() { *x = Thread_ParentThreadId{} - mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[9] + mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -914,7 +980,7 @@ func (x *Thread_ParentThreadId) String() string { func (*Thread_ParentThreadId) ProtoMessage() {} func (x *Thread_ParentThreadId) ProtoReflect() protoreflect.Message { - mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[9] + mi := &file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -965,7 +1031,7 @@ const file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc = "" + "\x04path\x18\x01 \x01(\tBq\xe0A\x05\xbaHKrI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$\u0091\x05\x1c\x12\x1atim.settlerlabs.com/ThreadR\x04path\x12F\n" + "\vbefore_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampB\t\xe0A\x05\xbaH\x03\xc8\x01\x01R\n" + "beforeTime:Z\xeaAW\n" + - "\x1atim.settlerlabs.com/thread\x12(orgs/{org}/users/{user}/threads/{thread}*\athreads2\x06thread\"\x81\v\n" + + "\x1atim.settlerlabs.com/thread\x12(orgs/{org}/users/{user}/threads/{thread}*\athreads2\x06thread\"\xad\v\n" + "\n" + "LlmMessage\x12\x82\x01\n" + "\x04path\x18\x01 \x01(\tBn\xe0A\x03\xbaHh\xd8\x01\x01rc2a^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}/messages/[a-fA-F0-9-]{36}$R\x04path\x12\x99\x01\n" + @@ -980,7 +1046,8 @@ const file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc = "" + "createTime\x12O\n" + "\vtoken_usage\x18\n" + " \x01(\v2).tim.api.llm_response.v1alpha1.TokenUsageB\x03\xe0A\x03R\n" + - "tokenUsage:\x80\x05\xeaAy\n" + + "tokenUsage\x12*\n" + + "\x0ehas_checkpoint\x18\v \x01(\bB\x03\xe0A\x03R\rhasCheckpoint:\x80\x05\xeaAy\n" + "\x1ftim.settlerlabs.com/llm-message\x12;orgs/{org}/users/{user}/threads/{thread}/messages/{message}*\fllm-messages2\vllm-message\xbaH\x80\x04\x1a\xe9\x01\n" + ")llm-message.role.valid_data_for_user_role\x12.users can only create text or tool_result data\x1a\x8b\x01this.role == tim.api.thread.v1alpha1.LlmMessageRole.LLM_MESSAGE_ROLE_USER? this.contents.exists(c, has(c.text) || has(c.tool_result)): true\x1a\x91\x02\n" + "(llm-message.role.valid_data_for_llm_role\x12Aassistant roles can only create text, thinking, or tool_call data\x1a\xa1\x01this.role == tim.api.thread.v1alpha1.LlmMessageRole.LLM_MESSAGE_ROLE_ASSISTANT? this.contents.exists(c, has(c.text) || has(c.thinking) || has(c.tool_call)): true\"L\n" + @@ -1011,7 +1078,10 @@ const file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc = "" + "\x16ThreadStateChangeEvent\x12I\n" + "\tllm_state\x18\x01 \x01(\x0e2'.tim.api.thread.v1alpha1.ThreadLLMStateB\x03\xe0A\x03R\bllmState\"(\n" + "\x10StreamErrorEvent\x12\x14\n" + - "\x05error\x18\x01 \x01(\tR\x05error*m\n" + + "\x05error\x18\x01 \x01(\tR\x05error\"U\n" + + "\x0fFileRestoration\x12\x1d\n" + + "\x04path\x18\x01 \x01(\tB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\x04path\x12#\n" + + "\acontent\x18\x02 \x01(\fB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\acontent*m\n" + "\x0eLlmMessageRole\x12 \n" + "\x1cLLM_MESSAGE_ROLE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15LLM_MESSAGE_ROLE_USER\x10\x01\x12\x1e\n" + @@ -1039,7 +1109,7 @@ func file_tim_api_thread_v1alpha1_thread_types_proto_rawDescGZIP() []byte { } var file_tim_api_thread_v1alpha1_thread_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_tim_api_thread_v1alpha1_thread_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_tim_api_thread_v1alpha1_thread_types_proto_goTypes = []any{ (LlmMessageRole)(0), // 0: tim.api.thread.v1alpha1.LlmMessageRole (ThreadLLMState)(0), // 1: tim.api.thread.v1alpha1.ThreadLLMState @@ -1053,29 +1123,30 @@ var file_tim_api_thread_v1alpha1_thread_types_proto_goTypes = []any{ (*ContentStopEvent)(nil), // 9: tim.api.thread.v1alpha1.ContentStopEvent (*ThreadStateChangeEvent)(nil), // 10: tim.api.thread.v1alpha1.ThreadStateChangeEvent (*StreamErrorEvent)(nil), // 11: tim.api.thread.v1alpha1.StreamErrorEvent - (*Thread_ParentThreadId)(nil), // 12: tim.api.thread.v1alpha1.Thread.ParentThreadId - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*v1alpha1.TokenUsage)(nil), // 14: tim.api.llm_response.v1alpha1.TokenUsage - (*v1alpha11.ToolCall)(nil), // 15: tim.api.tool.v1alpha1.ToolCall - (*v1alpha11.ToolResult)(nil), // 16: tim.api.tool.v1alpha1.ToolResult + (*FileRestoration)(nil), // 12: tim.api.thread.v1alpha1.FileRestoration + (*Thread_ParentThreadId)(nil), // 13: tim.api.thread.v1alpha1.Thread.ParentThreadId + (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*v1alpha1.TokenUsage)(nil), // 15: tim.api.llm_response.v1alpha1.TokenUsage + (*v1alpha11.ToolCall)(nil), // 16: tim.api.tool.v1alpha1.ToolCall + (*v1alpha11.ToolResult)(nil), // 17: tim.api.tool.v1alpha1.ToolResult } var file_tim_api_thread_v1alpha1_thread_types_proto_depIdxs = []int32{ - 12, // 0: tim.api.thread.v1alpha1.Thread.parent_thread_id:type_name -> tim.api.thread.v1alpha1.Thread.ParentThreadId - 13, // 1: tim.api.thread.v1alpha1.Thread.create_time:type_name -> google.protobuf.Timestamp - 13, // 2: tim.api.thread.v1alpha1.Thread.update_time:type_name -> google.protobuf.Timestamp + 13, // 0: tim.api.thread.v1alpha1.Thread.parent_thread_id:type_name -> tim.api.thread.v1alpha1.Thread.ParentThreadId + 14, // 1: tim.api.thread.v1alpha1.Thread.create_time:type_name -> google.protobuf.Timestamp + 14, // 2: tim.api.thread.v1alpha1.Thread.update_time:type_name -> google.protobuf.Timestamp 1, // 3: tim.api.thread.v1alpha1.Thread.llm_state:type_name -> tim.api.thread.v1alpha1.ThreadLLMState 0, // 4: tim.api.thread.v1alpha1.LlmMessage.role:type_name -> tim.api.thread.v1alpha1.LlmMessageRole 6, // 5: tim.api.thread.v1alpha1.LlmMessage.contents:type_name -> tim.api.thread.v1alpha1.LlmMessageContent - 13, // 6: tim.api.thread.v1alpha1.LlmMessage.create_time:type_name -> google.protobuf.Timestamp - 14, // 7: tim.api.thread.v1alpha1.LlmMessage.token_usage:type_name -> tim.api.llm_response.v1alpha1.TokenUsage + 14, // 6: tim.api.thread.v1alpha1.LlmMessage.create_time:type_name -> google.protobuf.Timestamp + 15, // 7: tim.api.thread.v1alpha1.LlmMessage.token_usage:type_name -> tim.api.llm_response.v1alpha1.TokenUsage 5, // 8: tim.api.thread.v1alpha1.LlmMessageContent.thinking:type_name -> tim.api.thread.v1alpha1.Thinking - 15, // 9: tim.api.thread.v1alpha1.LlmMessageContent.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall - 16, // 10: tim.api.thread.v1alpha1.LlmMessageContent.tool_result:type_name -> tim.api.tool.v1alpha1.ToolResult - 13, // 11: tim.api.thread.v1alpha1.LlmMessageContent.create_time:type_name -> google.protobuf.Timestamp + 16, // 9: tim.api.thread.v1alpha1.LlmMessageContent.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall + 17, // 10: tim.api.thread.v1alpha1.LlmMessageContent.tool_result:type_name -> tim.api.tool.v1alpha1.ToolResult + 14, // 11: tim.api.thread.v1alpha1.LlmMessageContent.create_time:type_name -> google.protobuf.Timestamp 0, // 12: tim.api.thread.v1alpha1.ContentStartEvent.role:type_name -> tim.api.thread.v1alpha1.LlmMessageRole 2, // 13: tim.api.thread.v1alpha1.ContentStartEvent.type:type_name -> tim.api.thread.v1alpha1.ContentType 1, // 14: tim.api.thread.v1alpha1.ThreadStateChangeEvent.llm_state:type_name -> tim.api.thread.v1alpha1.ThreadLLMState - 13, // 15: tim.api.thread.v1alpha1.Thread.ParentThreadId.before_time:type_name -> google.protobuf.Timestamp + 14, // 15: tim.api.thread.v1alpha1.Thread.ParentThreadId.before_time:type_name -> google.protobuf.Timestamp 16, // [16:16] is the sub-list for method output_type 16, // [16:16] is the sub-list for method input_type 16, // [16:16] is the sub-list for extension type_name @@ -1100,7 +1171,7 @@ func file_tim_api_thread_v1alpha1_thread_types_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc), len(file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc)), NumEnums: 3, - NumMessages: 10, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go b/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go index 9e849c2cd..5ef4952f6 100644 --- a/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go +++ b/tim-proto/gen/tim/api/thread/v1alpha1/threadv1alpha1connect/thread_service.connect.go @@ -91,7 +91,7 @@ type ThreadServiceClient interface { // Submit a user message to a thread SubmitUserMessage(context.Context, *connect.Request[v1alpha1.SubmitUserMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) // Edit a thread message (with checkpoint restoration support) - EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.EditThreadMessageResponse], error) // Configure the working directory for a thread (for checkpoint creation) ConfigureThreadWorkingDirectory(context.Context, *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) } @@ -161,7 +161,7 @@ func NewThreadServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(threadServiceMethods.ByName("SubmitUserMessage")), connect.WithClientOptions(opts...), ), - editThreadMessage: connect.NewClient[v1alpha1.EditThreadMessageRequest, v1alpha1.LlmMessage]( + editThreadMessage: connect.NewClient[v1alpha1.EditThreadMessageRequest, v1alpha1.EditThreadMessageResponse]( httpClient, baseURL+ThreadServiceEditThreadMessageProcedure, connect.WithSchema(threadServiceMethods.ByName("EditThreadMessage")), @@ -187,7 +187,7 @@ type threadServiceClient struct { listLlmMessages *connect.Client[v1alpha1.ListLlmMessagesRequest, v1alpha1.ListLlmMessagesResponse] streamThreadEvents *connect.Client[v1alpha1.StreamThreadEventsRequest, v1alpha1.StreamThreadEventsResponse] submitUserMessage *connect.Client[v1alpha1.SubmitUserMessageRequest, v1alpha1.LlmMessage] - editThreadMessage *connect.Client[v1alpha1.EditThreadMessageRequest, v1alpha1.LlmMessage] + editThreadMessage *connect.Client[v1alpha1.EditThreadMessageRequest, v1alpha1.EditThreadMessageResponse] configureThreadWorkingDirectory *connect.Client[v1alpha1.ConfigureThreadWorkingDirectoryRequest, emptypb.Empty] } @@ -237,7 +237,7 @@ func (c *threadServiceClient) SubmitUserMessage(ctx context.Context, req *connec } // EditThreadMessage calls tim.api.thread.v1alpha1.ThreadService.EditThreadMessage. -func (c *threadServiceClient) EditThreadMessage(ctx context.Context, req *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) { +func (c *threadServiceClient) EditThreadMessage(ctx context.Context, req *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.EditThreadMessageResponse], error) { return c.editThreadMessage.CallUnary(ctx, req) } @@ -270,7 +270,7 @@ type ThreadServiceHandler interface { // Submit a user message to a thread SubmitUserMessage(context.Context, *connect.Request[v1alpha1.SubmitUserMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) // Edit a thread message (with checkpoint restoration support) - EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) + EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.EditThreadMessageResponse], error) // Configure the working directory for a thread (for checkpoint creation) ConfigureThreadWorkingDirectory(context.Context, *connect.Request[v1alpha1.ConfigureThreadWorkingDirectoryRequest]) (*connect.Response[emptypb.Empty], error) } @@ -417,7 +417,7 @@ func (UnimplementedThreadServiceHandler) SubmitUserMessage(context.Context, *con return nil, connect.NewError(connect.CodeUnimplemented, errors.New("tim.api.thread.v1alpha1.ThreadService.SubmitUserMessage is not implemented")) } -func (UnimplementedThreadServiceHandler) EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.LlmMessage], error) { +func (UnimplementedThreadServiceHandler) EditThreadMessage(context.Context, *connect.Request[v1alpha1.EditThreadMessageRequest]) (*connect.Response[v1alpha1.EditThreadMessageResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("tim.api.thread.v1alpha1.ThreadService.EditThreadMessage is not implemented")) } diff --git a/tim-proto/gen/tim/api/thread_context/v1alpha1/thread_context_service.swagger.json b/tim-proto/gen/tim/api/thread_context/v1alpha1/thread_context_service.swagger.json index 5ac0932ad..7ffc2e1b0 100644 --- a/tim-proto/gen/tim/api/thread_context/v1alpha1/thread_context_service.swagger.json +++ b/tim-proto/gen/tim/api/thread_context/v1alpha1/thread_context_service.swagger.json @@ -165,6 +165,11 @@ "$ref": "#/definitions/v1alpha1TokenUsage", "title": "Token usage for this message (optional, set after LLM response)", "readOnly": true + }, + "hasCheckpoint": { + "type": "boolean", + "title": "Whether a checkpoint exists for this message", + "readOnly": true } }, "description": "LlmMessages are messages that make up a thread."