diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index be89d5f54..886120d9c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -118,6 +118,13 @@ jobs: mock: true secrets: inherit + e2e-test-thread-forking: + uses: ./.github/workflows/reusable-e2e-test.yml + with: + test-name: "thread-forking" + mock: true + secrets: inherit + e2e-tests-complete: needs: [ 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 83d234a59..30285c1be 100644 --- a/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto +++ b/proto/tim-api/tim/api/thread/v1alpha1/thread_service.proto @@ -8,7 +8,6 @@ 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"; @@ -37,13 +36,13 @@ service ThreadService { option (google.api.method_signature) = "parent,thread,initial_message_text"; } - // Fork an existing thread to create a new thread + // Fork a thread at a specific message (inclusive) rpc ForkThread(ForkThreadRequest) returns (ForkThreadResponse) { option (google.api.http) = { - post: "/v1alpha1/{parent=orgs/*/users/*/threads/*}:fork" + post: "/v1alpha1/{message=orgs/*/users/*/threads/*/messages/*}:fork" body: "*" }; - option (google.api.method_signature) = "parent,thread"; + option (google.api.method_signature) = "message"; } // Update mutable fields on a thread @@ -171,27 +170,24 @@ message CreateThreadRequest { ]; } -// CreateThreadRequest is used to create a new thread, which must be unique within -// the parent thread if one is provided. +// ForkThreadRequest is used to fork a thread at a specific message message ForkThreadRequest { - // The resource path of the thread to fork from - string parent = 1 [ + // The resource path of the user message to fork before (non-inclusive). + // The forked thread will contain all messages BEFORE this message. + // Must be a user message and cannot be the first message (index 0). + string message = 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}$" + (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 timestamp from the parent thread to fork from, all events on the parent - // thread before this time will be included in the new thread. - google.protobuf.Timestamp before_time = 2 [ - (buf.validate.field).required = true, - (google.api.field_behavior) = REQUIRED - ]; + // Optional title for the forked thread. If not provided, will be auto-generated. + optional string title = 2 [(buf.validate.field).string.max_len = 255]; } // ForkThreadResponse is the response for forking a thread message ForkThreadResponse { - // The newly created thread + // The newly created forked thread Thread thread = 1 [ (buf.validate.field).required = true, (google.api.field_behavior) = REQUIRED @@ -330,6 +326,12 @@ message EditThreadMessageRequest { // 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; + + // If true, creates a new forked thread instead of modifying the original thread. + // The forked thread will include all messages before the edited message, + // and the edited content will be added as a new message in the forked thread. + // The original thread remains unchanged. + optional bool create_fork = 4 [(google.api.field_behavior) = OPTIONAL]; } // EditThreadMessageResponse contains the edited message and any file restoration data @@ -343,6 +345,9 @@ message EditThreadMessageResponse { // Files to restore from checkpoint (if applicable) // The client should restore these files before continuing repeated FileRestoration file_restorations = 2; + + // If create_fork was true in the request, this contains the newly created forked thread + optional Thread forked_thread = 3; } // 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 9f96676c0..71d685576 100644 --- a/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto +++ b/proto/tim-api/tim/api/thread/v1alpha1/thread_types.proto @@ -45,6 +45,12 @@ message Thread { (google.api.field_behavior) = IMMUTABLE, (buf.validate.field).required = true ]; + // The message at which this thread was forked (optional for backward compatibility) + // If set, this is the exact message that served as the fork point. + string fork_message_uid = 3 [ + (google.api.field_behavior) = OUTPUT_ONLY, + (google.api.field_info).format = UUID4 + ]; } // If this thread was forked from another thread, this is the ID of that thread diff --git a/shared/apiclient/convenience.go b/shared/apiclient/convenience.go index 907c17ec6..01c7701f5 100644 --- a/shared/apiclient/convenience.go +++ b/shared/apiclient/convenience.go @@ -3,7 +3,6 @@ package apiclient import ( "context" "fmt" - "time" "connectrpc.com/connect" orgv1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/org/v1alpha1" @@ -11,7 +10,6 @@ import ( threadv1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1" todov1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/todo/v1alpha1" userv1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/user/v1alpha1" - "google.golang.org/protobuf/types/known/timestamppb" ) // Common pagination options @@ -179,19 +177,6 @@ func (c *Client) CreateThreadWithPrompt(ctx context.Context, orgID, userID strin return resp.Msg, nil } -// ForkThreadAtTime forks a thread at a specific timestamp -func (c *Client) ForkThreadAtTime(ctx context.Context, threadPath string, beforeTime time.Time) (*threadv1alpha1.Thread, error) { - req := &threadv1alpha1.ForkThreadRequest{ - Parent: threadPath, - BeforeTime: timestamppb.New(beforeTime), - } - resp, err := c.Thread.ForkThread(ctx, connect.NewRequest(req)) - if err != nil { - return nil, c.handleError(err) - } - return resp.Msg.Thread, nil -} - // ListThreadsWithDefaults lists threads with default pagination func (c *Client) ListThreadsWithDefaults(ctx context.Context, orgID, userID string) (*threadv1alpha1.ListThreadsResponse, error) { return c.ListThreadsWithPagination(ctx, orgID, userID, PaginationOptions{ diff --git a/tests/system/framework/api_client.py b/tests/system/framework/api_client.py index 4f7dfd6b6..b37cb1cfe 100644 --- a/tests/system/framework/api_client.py +++ b/tests/system/framework/api_client.py @@ -233,7 +233,7 @@ 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, restore: bool = False + self, message_path: str, content: str, restore: bool = False, create_fork: bool = False ) -> dict[str, Any]: """Edit a thread message. @@ -243,14 +243,43 @@ def edit_thread_message( 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). + create_fork: If true, creates a new forked thread instead of modifying the original thread. Returns: - The updated message with optional file_restorations field + The updated message with optional file_restorations and forked_thread fields """ + data: dict[str, Any] = {"content": content, "restore": restore} + if create_fork: + data["create_fork"] = create_fork + response = self.client.post( self._url(f"{message_path}:edit"), headers=self._default_headers, - json={"content": content, "restore": restore}, + json=data, + ) + response.raise_for_status() + return response.json() + + def fork_thread_from_message( + self, message_path: str, title: str | None = None + ) -> dict[str, Any]: + """Fork a thread at a specific message. + + Args: + message_path: Path to the message to fork from (inclusive) + title: Optional title for the forked thread + + Returns: + The forked thread response + """ + data: dict[str, Any] = {} + if title: + data["title"] = title + + response = self.client.post( + self._url(f"{message_path}:fork"), + headers=self._default_headers, + json=data, ) response.raise_for_status() return response.json() diff --git a/tests/system/framework/models/__init__.py b/tests/system/framework/models/__init__.py index 844c1a022..bfbd915ad 100644 --- a/tests/system/framework/models/__init__.py +++ b/tests/system/framework/models/__init__.py @@ -41,6 +41,7 @@ LlmMessageContent, LlmMessageRole, MessageList, + ParentThreadId, Thinking, Thread, ThreadList, @@ -73,6 +74,7 @@ "LlmMessageContent", "LlmMessageRole", "MessageList", + "ParentThreadId", "Thinking", "Thread", "ThreadList", diff --git a/tests/system/framework/models/thread.py b/tests/system/framework/models/thread.py index 4387c2513..c3a92517f 100644 --- a/tests/system/framework/models/thread.py +++ b/tests/system/framework/models/thread.py @@ -27,11 +27,22 @@ class LlmMessageRole(str, Enum): ASSISTANT = "LLM_MESSAGE_ROLE_ASSISTANT" +class ParentThreadId(BaseModel): + """Parent thread reference for forked threads.""" + + path: str + before_time: str | None = Field(None, alias="beforeTime") + fork_message_uid: str | None = Field(None, alias="forkMessageUid") + + model_config = ConfigDict(populate_by_alias=True) + + class Thread(BaseModel): """Thread resource.""" path: str display_name: str | None = Field(None, alias="displayName") + parent_thread_id: ParentThreadId | None = Field(None, alias="parentThreadId") persona_uid: str | None = Field(None, alias="personaUid") environment_uid: str | None = Field(None, alias="environmentUid") create_time: str | None = Field(None, alias="createTime") diff --git a/tests/system/responses/claude-4-5-haiku-thread-forking.json b/tests/system/responses/claude-4-5-haiku-thread-forking.json new file mode 100644 index 000000000..c0113c926 --- /dev/null +++ b/tests/system/responses/claude-4-5-haiku-thread-forking.json @@ -0,0 +1,2256 @@ +{ + "version": "1.0", + "session": { + "id": 1, + "session_name": "claude-4-5-haiku-thread-forking", + "created_at": "2025-11-07T16:57:32Z", + "description": "Proxy recording session" + }, + "interactions": [ + { + "request_id": "86e9c8b8-8bc6-4f10-acb7-273b35ac9973", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "2997", + "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": "What is 2+2?", + "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-07T16:57:35Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:35Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:35Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:35Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5ed96bbc633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:36 GMT", + "Request-Id": "req_011CUttAQuWg2NzSzwWGEUV7", + "Retry-After": "25", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "1187", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:36.198254-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_014Cc27EXCeH4viVau6daZAC\",\"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\":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", + "time_delta": 10 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"2 \"}}\n\n", + "time_delta": 0 + }, + { + "chunk_index": 3, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 22 + }, + { + "chunk_index": 4, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"+ 2 = 4\"} }\n\n", + "time_delta": 11 + }, + { + "chunk_index": 5, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 113 + }, + { + "chunk_index": 6, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01DyEZ9UBoY976VWDQpezMm7\",\"name\":\"work_complete\",\"input\":{}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"sum\"} }\n\n", + "time_delta": 77 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"mar\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"y\\\": \\\"2 \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"+ 2 = 4\\\"}\"}}\n\n", + "time_delta": 1 + }, + { + "chunk_index": 12, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\n", + "time_delta": 5 + }, + { + "chunk_index": 13, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":968,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":63} }\n\n", + "time_delta": 11 + }, + { + "chunk_index": 14, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "39a3dfaa-6046-4751-ae58-97caea9f0a2e", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3410", + "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": "What is 2+2?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "2 + 2 = 4", + "type": "text" + }, + { + "id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "input": { + "summary": "2 + 2 = 4" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "What is 3+3?", + "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-07T16:57:39Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:39Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:39Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:39Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5ef288c2633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:39 GMT", + "Request-Id": "req_011CUttAhyvZEu5Loq9fd8q5", + "Retry-After": "23", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "567", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:39.584556-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_01XDwfaT6bAqiZXVrt6BxmCg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1066,\"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": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"3 + 3 =\"} }\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\":\" 6\"} }\n\n", + "time_delta": 30 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 89 + }, + { + "chunk_index": 5, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 6, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 3 + }, + { + "chunk_index": 7, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01D4uqfBAUumrBs66PnavxuQ\",\"name\":\"work_complete\",\"input\":{}} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 8, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 3 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\n", + "time_delta": 2 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"summary\"} }\n\n", + "time_delta": 153 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"3 + \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"3 = 6\\\"}\"}}\n\n", + "time_delta": 0 + }, + { + "chunk_index": 13, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 14, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1066,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":63} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 15, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "60031bf2-82ce-4578-9050-9053178786fb", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3823", + "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": "What is 2+2?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "2 + 2 = 4", + "type": "text" + }, + { + "id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "input": { + "summary": "2 + 2 = 4" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "What is 3+3?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "3 + 3 = 6", + "type": "text" + }, + { + "id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "input": { + "summary": "3 + 3 = 6" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "What is 4+4?", + "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-07T16:57:42Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:42Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:42Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:42Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5f057824633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:42 GMT", + "Request-Id": "req_011CUttAvpbQJ8LJKbeK66kU", + "Retry-After": "18", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "810", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:42.81271-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_01KUiRYtjqRCwJH9bUWcgXXz\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1164,\"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": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"4 + 4 =\"} }\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\":\" 8\"} }\n\n", + "time_delta": 59 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 66 + }, + { + "chunk_index": 5, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 6, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 4 + }, + { + "chunk_index": 7, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01HLWKqmKiwqnTMm21SPYFgB\",\"name\":\"work_complete\",\"input\":{}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 8, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 0 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"summar\"} }\n\n", + "time_delta": 109 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"y\\\": \\\"4 \"}}\n\n", + "time_delta": 8 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"+ 4 = 8\\\"}\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 13, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 14, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1164,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":63} }\n\n", + "time_delta": 12 + }, + { + "chunk_index": 15, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "187b8a3d-49e1-4267-abb7-ebdc17febd46", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "3439", + "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": "What is 2+2?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "2 + 2 = 4", + "type": "text" + }, + { + "id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "input": { + "summary": "2 + 2 = 4" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "Can you help me with something different?", + "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-07T16:57:47Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:47Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:47Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:47Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5f251d62633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:47 GMT", + "Request-Id": "req_011CUttBJZHw6RrgNQHfxnr2", + "Retry-After": "14", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "648", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:47.74492-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_01Q3z3s8mU54e9bfUeg9umZG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1067,\"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", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Of\"} }\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\":\" course! I'd be happy to help\"} }\n\n", + "time_delta": 54 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 26 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you with something different. 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\":\"do you need assistance with?\"} }\n\n", + "time_delta": 9 + }, + { + "chunk_index": 7, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 8, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1067,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":24} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 9, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 3 + } + ] + }, + { + "request_id": "ec9987f4-c7cc-43c0-8803-f7073e6a5b47", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "4249", + "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": "What is 2+2?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "2 + 2 = 4", + "type": "text" + }, + { + "id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "input": { + "summary": "2 + 2 = 4" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "What is 3+3?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "3 + 3 = 6", + "type": "text" + }, + { + "id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "input": { + "summary": "3 + 3 = 6" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "What is 4+4?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "4 + 4 = 8", + "type": "text" + }, + { + "id": "toolu_01HLWKqmKiwqnTMm21SPYFgB", + "input": { + "summary": "4 + 4 = 8" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01HLWKqmKiwqnTMm21SPYFgB", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "Tell me about JavaScript.", + "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-07T16:57:49Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:49Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:49Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:49Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5f31edeb633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:49 GMT", + "Request-Id": "req_011CUttBTDwZPcEUYFjmAqTB", + "Retry-After": "11", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "795", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:49.954615-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_01DSvPm6TdgeENwZfNcgZh6y\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1260,\"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", + "time_delta": 0 + }, + { + "chunk_index": 2, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"JavaScript\"} }\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\":\" is a versat\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 79 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ile, lightweight\"} }\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\":\" programming language primarily used for web development. Here are the\"} }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 7, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" key points:\"} }\n\n", + "time_delta": 103 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n**Core Features:**\\n-\"} }\n\n", + "time_delta": 120 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Runs\"} }\n\n", + "time_delta": 8 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in\"} }\n\n", + "time_delta": 7 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" web\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" browsers an\"} }\n\n", + "time_delta": 19 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d on\"} }\n\n", + "time_delta": 9 + }, + { + "chunk_index": 14, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" servers\"} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 15, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" (\"} }\n\n", + "time_delta": 30 + }, + { + "chunk_index": 16, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Node.js)\\n- Dynam\"} }\n\n", + "time_delta": 198 + }, + { + "chunk_index": 17, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ically typed an\"} }\n\n", + "time_delta": 7 + }, + { + "chunk_index": 18, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d weak\"} }\n\n", + "time_delta": 24 + }, + { + "chunk_index": 19, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ly typed language\\n- Supports\"} }\n\n", + "time_delta": 12 + }, + { + "chunk_index": 20, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" object-oriented,\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 21, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" functional, and event\"} }\n\n", + "time_delta": 36 + }, + { + "chunk_index": 22, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-driven programming\\n- As\"} }\n\n", + "time_delta": 29 + }, + { + "chunk_index": 23, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ynchronous capabilities\"} }\n\n", + "time_delta": 29 + }, + { + "chunk_index": 24, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with\"} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 25, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" callbacks, promises, and async/await\"} }\n\n", + "time_delta": 27 + }, + { + "chunk_index": 26, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n**Common\"} }\n\n", + "time_delta": 57 + }, + { + "chunk_index": 27, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Uses:**\\n- Interactive\"}}\n\n", + "time_delta": 37 + }, + { + "chunk_index": 28, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" web pages an\"} }\n\n", + "time_delta": 104 + }, + { + "chunk_index": 29, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d user interfaces\\n- Fronten\"} }\n\n", + "time_delta": 55 + }, + { + "chunk_index": 30, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d frameworks (\"} }\n\n", + "time_delta": 39 + }, + { + "chunk_index": 31, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"React, Vue, Angular)\\n-\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 32, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Backend development (\"} }\n\n", + "time_delta": 20 + }, + { + "chunk_index": 33, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Node.js)\\n- Mobile app\"} }\n\n", + "time_delta": 89 + }, + { + "chunk_index": 34, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" development (\"} }\n\n", + "time_delta": 144 + }, + { + "chunk_index": 35, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"React Native,\"} }\n\n", + "time_delta": 7 + }, + { + "chunk_index": 36, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Cor\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 37, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"dova)\\n- Server\"} }\n\n", + "time_delta": 14 + }, + { + "chunk_index": 38, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-side scri\"} }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 39, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"pting and APIs\"} }\n\n", + "time_delta": 199 + }, + { + "chunk_index": 40, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n**Key Characteristics:**\\n-\"}}\n\n", + "time_delta": 11 + }, + { + "chunk_index": 41, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Event-driven an\"} }\n\n", + "time_delta": 11 + }, + { + "chunk_index": 42, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d DOM manipulation\"} }\n\n", + "time_delta": 15 + }, + { + "chunk_index": 43, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- First\"} }\n\n", + "time_delta": 8 + }, + { + "chunk_index": 44, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-class functions and closures\\n-\"} }\n\n", + "time_delta": 32 + }, + { + "chunk_index": 45, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Prototype-based inheritance\\n- Large\"} }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 46, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ecosystem with npm\"} }\n\n", + "time_delta": 5 + }, + { + "chunk_index": 47, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" package manager\\n- Continuously\"} }\n\n", + "time_delta": 4 + }, + { + "chunk_index": 48, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" evolving with ES\"} }\n\n", + "time_delta": 35 + }, + { + "chunk_index": 49, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"6+ features\\n\\n**Popular\"}}\n\n", + "time_delta": 15 + }, + { + "chunk_index": 50, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Libraries &\"}}\n\n", + "time_delta": 49 + }, + { + "chunk_index": 51, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Frameworks:**\\n- React\"} }\n\n", + "time_delta": 57 + }, + { + "chunk_index": 52, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", Vue.js, Angular for\"} }\n\n", + "time_delta": 51 + }, + { + "chunk_index": 53, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" frontend\\n- Express\"} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 54, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".js for backend\"} }\n\n", + "time_delta": 99 + }, + { + "chunk_index": 55, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- Next.js for full\"} }\n\n", + "time_delta": 46 + }, + { + "chunk_index": 56, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-stack applications\"} }\n\n", + "time_delta": 78 + }, + { + "chunk_index": 57, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nJavaScript has\"} }\n\n", + "time_delta": 67 + }, + { + "chunk_index": 58, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" become one of the most popular programming languages\"}}\n\n", + "time_delta": 15 + }, + { + "chunk_index": 59, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" due to its flexibility\"} }\n\n", + "time_delta": 62 + }, + { + "chunk_index": 60, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", ease of learning\"} }\n\n", + "time_delta": 16 + }, + { + "chunk_index": 61, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", and ubiq\"} }\n\n", + "time_delta": 69 + }, + { + "chunk_index": 62, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"uitous presence\"} }\n\n", + "time_delta": 25 + }, + { + "chunk_index": 63, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" across web\"}}\n\n", + "time_delta": 62 + }, + { + "chunk_index": 64, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" platforms.\"}}\n\n", + "time_delta": 47 + }, + { + "chunk_index": 65, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 205 + }, + { + "chunk_index": 66, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01D9Z5yeL2Sgzc6tPMQd59Jv\",\"name\":\"work_complete\",\"input\":{}} }\n\n", + "time_delta": 5 + }, + { + "chunk_index": 67, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\n", + "time_delta": 9 + }, + { + "chunk_index": 68, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\"} }\n\n", + "time_delta": 276 + }, + { + "chunk_index": 69, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"summary\"}}\n\n", + "time_delta": 7 + }, + { + "chunk_index": 70, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"Prov\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 71, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ide\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 72, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"d an ov\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 73, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"erv\"}}\n\n", + "time_delta": 1 + }, + { + "chunk_index": 74, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"iew o\"} }\n\n", + "time_delta": 2 + }, + { + "chunk_index": 75, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"f JavaScrip\"} }\n\n", + "time_delta": 2 + }, + { + "chunk_index": 76, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"t c\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 77, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"overing its\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 78, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" core\"} }\n\n", + "time_delta": 3 + }, + { + "chunk_index": 79, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" features\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 80, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", common use\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 81, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s, key\"} }\n\n", + "time_delta": 3 + }, + { + "chunk_index": 82, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" cha\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 83, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"racteris\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 84, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"tics, a\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 85, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"nd po\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 86, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"pular framew\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 87, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"orks/libra\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 88, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ries in we\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 89, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"b develo\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 90, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"pment.\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 91, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 92, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 93, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1260,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":322} }\n\n", + "time_delta": 4 + }, + { + "chunk_index": 94, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + }, + { + "request_id": "24d73631-c09f-41ef-b434-94a46af11718", + "protocol": "REST", + "method": "POST", + "endpoint": "/v1/messages", + "request": { + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Anthropic-Version": "2023-06-01", + "Content-Length": "4251", + "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": "What is 2+2?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "2 + 2 = 4", + "type": "text" + }, + { + "id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "input": { + "summary": "2 + 2 = 4" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01DyEZ9UBoY976VWDQpezMm7", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "What is 3+3?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "3 + 3 = 6", + "type": "text" + }, + { + "id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "input": { + "summary": "3 + 3 = 6" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01D4uqfBAUumrBs66PnavxuQ", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "What is 4+4?", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "4 + 4 = 8", + "type": "text" + }, + { + "id": "toolu_01HLWKqmKiwqnTMm21SPYFgB", + "input": { + "summary": "4 + 4 = 8" + }, + "name": "work_complete", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": [ + { + "text": "Work complete", + "type": "text" + } + ], + "is_error": false, + "tool_use_id": "toolu_01HLWKqmKiwqnTMm21SPYFgB", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "Tell me about Rust instead.", + "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-07T16:57:55Z", + "Anthropic-Ratelimit-Output-Tokens-Limit": "800000", + "Anthropic-Ratelimit-Output-Tokens-Remaining": "800000", + "Anthropic-Ratelimit-Output-Tokens-Reset": "2025-11-07T16:57:55Z", + "Anthropic-Ratelimit-Requests-Limit": "4000", + "Anthropic-Ratelimit-Requests-Remaining": "3999", + "Anthropic-Ratelimit-Requests-Reset": "2025-11-07T16:57:55Z", + "Anthropic-Ratelimit-Tokens-Limit": "4800000", + "Anthropic-Ratelimit-Tokens-Remaining": "4799000", + "Anthropic-Ratelimit-Tokens-Reset": "2025-11-07T16:57:55Z", + "Cache-Control": "no-cache", + "Cf-Cache-Status": "DYNAMIC", + "Cf-Ray": "99ae5f57ba36633b-DFW", + "Content-Type": "text/event-stream; charset=utf-8", + "Date": "Fri, 07 Nov 2025 16:57:56 GMT", + "Request-Id": "req_011CUttBuB9cy5cdQTTYKG3Q", + "Retry-After": "5", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Envoy-Upstream-Service-Time": "1495", + "X-Robots-Tag": "none" + }, + "body": null + }, + "timestamp": "2025-11-07T10:57:56.686266-06:00", + "sequence_number": 6, + "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_014sXB4SWX8sFS89CxQu44MJ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1262,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"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\":\"Rust is a modern\"} }\n\n", + "time_delta": 8 + }, + { + "chunk_index": 3, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" systems programming language that priorit\"} }\n\n", + "time_delta": 41 + }, + { + "chunk_index": 4, + "data": "event: ping\ndata: {\"type\": \"ping\"}\n\n", + "time_delta": 33 + }, + { + "chunk_index": 5, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"izes safety, spee\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 6, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d, and concurrency. It\"} }\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\":\" eliminates common bugs like null pointer dereferences\"} }\n\n", + "time_delta": 94 + }, + { + "chunk_index": 8, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" and data races through\"} }\n\n", + "time_delta": 761 + }, + { + "chunk_index": 9, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" its ownership system and b\"} }\n\n", + "time_delta": 20 + }, + { + "chunk_index": 10, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"orrow checker, which\"} }\n\n", + "time_delta": 80 + }, + { + "chunk_index": 11, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" enforce memory safety at compile time without\"} }\n\n", + "time_delta": 55 + }, + { + "chunk_index": 12, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" needing garbage collection.\\n\\nKey features include\"} }\n\n", + "time_delta": 35 + }, + { + "chunk_index": 13, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\":\\n- **Memory safety without\"} }\n\n", + "time_delta": 48 + }, + { + "chunk_index": 14, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" garbage collection**: The\"} }\n\n", + "time_delta": 4 + }, + { + "chunk_index": 15, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ownership system ensures memory is managed safely\"} }\n\n", + "time_delta": 15 + }, + { + "chunk_index": 16, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- **Zero-cost abstractions\"} }\n\n", + "time_delta": 23 + }, + { + "chunk_index": 17, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"**: High-\"} }\n\n", + "time_delta": 7 + }, + { + "chunk_index": 18, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"level features\"} }\n\n", + "time_delta": 20 + }, + { + "chunk_index": 19, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" compile down to efficient\"} }\n\n", + "time_delta": 5 + }, + { + "chunk_index": 20, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" machine code\\n- **Conc\"} }\n\n", + "time_delta": 17 + }, + { + "chunk_index": 21, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"urrency**: Built-\"} }\n\n", + "time_delta": 7 + }, + { + "chunk_index": 22, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"in tools for safe\"} }\n\n", + "time_delta": 22 + }, + { + "chunk_index": 23, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" concurrent programming\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 24, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- **Strong\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 25, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" type system**: Catches\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 26, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" many errors at compile time\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 27, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- **Performance**:\"} }\n\n", + "time_delta": 6 + }, + { + "chunk_index": 28, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Comparable to C an\"} }\n\n", + "time_delta": 11 + }, + { + "chunk_index": 29, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d C++\"} }\n\n", + "time_delta": 9 + }, + { + "chunk_index": 30, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nRust is widely used for systems\"} }\n\n", + "time_delta": 12 + }, + { + "chunk_index": 31, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" programming, web servers\"} }\n\n", + "time_delta": 2 + }, + { + "chunk_index": 32, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", blockchain projects, embedde\"} }\n\n", + "time_delta": 61 + }, + { + "chunk_index": 33, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d systems, and performance-critical applications.\"} }\n\n", + "time_delta": 30 + }, + { + "chunk_index": 34, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" It has a friendly an\"} }\n\n", + "time_delta": 51 + }, + { + "chunk_index": 35, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d active community an\"} }\n\n", + "time_delta": 70 + }, + { + "chunk_index": 36, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d growing ecosystem of libraries\"} }\n\n", + "time_delta": 42 + }, + { + "chunk_index": 37, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" (crates).\"}}\n\n", + "time_delta": 46 + }, + { + "chunk_index": 38, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\n", + "time_delta": 123 + }, + { + "chunk_index": 39, + "data": "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01UG429wDcw68iZk5tfbm3c4\",\"name\":\"work_complete\",\"input\":{}} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 40, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\n", + "time_delta": 13 + }, + { + "chunk_index": 41, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"su\"} }\n\n", + "time_delta": 373 + }, + { + "chunk_index": 42, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"mma\"} }\n\n", + "time_delta": 17 + }, + { + "chunk_index": 43, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ry\\\": \\\"Pro\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 44, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"vided \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 45, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"an overvi\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 46, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ew of\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 47, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" Ru\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 48, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st pro\"} }\n\n", + "time_delta": 13 + }, + { + "chunk_index": 49, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"gram\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 50, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ming languag\"} }\n\n", + "time_delta": 3 + }, + { + "chunk_index": 51, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"e, coveri\"} }\n\n", + "time_delta": 1 + }, + { + "chunk_index": 52, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ng it\"} }\n\n", + "time_delta": 17 + }, + { + "chunk_index": 53, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s key featur\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 54, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"es includin\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 55, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"g mem\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 56, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"or\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 57, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"y saf\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 58, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ety, zero\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 59, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"-co\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 60, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st abst\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 61, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ractions, co\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 62, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ncurrency\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 63, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" sup\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 64, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"port,\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 65, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" str\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 66, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ong type \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 67, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"system, a\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 68, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"nd per\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 69, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"for\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 70, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"mance \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 71, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"chara\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 72, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"cter\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 73, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"istics, \"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 74, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"along with\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 75, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" common \"} }\n\n", + "time_delta": 9 + }, + { + "chunk_index": 76, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"use cases\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 77, + "data": "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\".\\\"}\"} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 78, + "data": "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 79, + "data": "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1262,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":263} }\n\n", + "time_delta": 0 + }, + { + "chunk_index": 80, + "data": "event: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "time_delta": 0 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/system/test_thread_forking.py b/tests/system/test_thread_forking.py new file mode 100644 index 000000000..4af950a93 --- /dev/null +++ b/tests/system/test_thread_forking.py @@ -0,0 +1,680 @@ +"""Thread forking end-to-end test. + +Tests thread forking functionality: + 1. Create org, user, and persona + 2. Create thread with initial message and get multiple responses + 3. Test Method 1: Fork thread using ForkThreadFromMessage endpoint + 4. Verify forked thread has correct messages up to (but NOT including) fork point (non-inclusive) + 5. Verify original thread remains unchanged + 6. Test fork-of-fork: Fork an already-forked thread + 7. Verify messages are correctly copied through nested forks + 8. Test Method 2: Fork thread using EditThreadMessage with create_fork=true + 9. Verify forked thread via edit has correct structure + 10. Verify all fork methods create independent threads + 11. Test that forked threads can continue independently + +Running this test: + # From the workspace root + just testing::test-system-one thread-forking + + # Or with specific model + just testing::test-system-one thread-forking -- --model-id=claude-4-5-sonnet + + # Or directly with pytest from tests/system directory + cd tests/system + uv run pytest -v -s test_thread_forking.py +""" + +import threading +import time + +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 test_thread_forking( + api_client: TimApiClient, + thread_poller: ThreadPoller, + test_org: Org, + test_user: User, + model_id: str, + test_summary, +): + """Test thread forking using both available methods. + + This test verifies: + 1. Creating a thread with multiple messages + 2. METHOD 1: Forking via ForkThreadFromMessage endpoint + 3. Verifying the forked thread includes messages up to (but NOT including) the fork point (non-inclusive) + 4. Verifying the original thread remains unchanged + 5. FORK-OF-FORK: Forking an already-forked thread + 6. Verifying messages are correctly propagated through nested forks + 7. METHOD 2: Forking via EditThreadMessage with create_fork=true + 8. Verifying the forked thread via edit has correct structure and edited content + 9. Verifying all fork methods create independent threads + 10. Testing that forked threads can continue independently + """ + test_summary.org = test_org + test_summary.user = test_user + + # Create persona with work_complete tool + persona = api_client.create_persona( + test_org.org_id, + test_user.user_id, + display_name="Fork Test Persona", + description="Test persona for forking", + ) + test_summary.persona = persona + + # Create persona revision without thinking for simpler 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 original thread with initial message...") + thread = api_client.create_thread( + test_org.org_id, + test_user.user_id, + persona.persona_id, + display_name="Original Thread for Fork Test", + initial_message_text="What is 2+2?", + ) + print(f" Created thread: {thread.thread_id}") + test_summary.thread = thread + + # 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 + 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") + + time.sleep(2) # Wait for DB commit + print(" ✓ Initial message completed") + + # Get messages after first response + messages = api_client.list_messages(thread.path) + print(f" Messages after initial: {len(messages.results)}") + + # Submit second user message + print("\nSubmitting second user message...") + api_client.submit_user_message(thread.path, "What is 3+3?") + print(" ✓ Message 2 submitted") + + # Wait for second message to complete + 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") + + time.sleep(2) # Wait for DB commit + print(" ✓ Second message completed") + + # Submit third user message + print("\nSubmitting third user message...") + api_client.submit_user_message(thread.path, "What is 4+4?") + print(" ✓ Message 3 submitted") + + # Wait for third message to complete + 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") + + time.sleep(2) # Wait for DB commit + print(" ✓ Third message completed") + + # Get all messages from original thread + original_messages = api_client.list_messages(thread.path) + original_message_count = len(original_messages.results) + print(f"\nOriginal thread has {original_message_count} messages") + + # Find the second user message to fork from by index and role + # The second user message we submitted should be at index 3 (after initial user, assistant, tool_result) + fork_target_message = None + user_message_count = 0 + for msg in original_messages.results: + if msg.role == "LLM_MESSAGE_ROLE_USER" and any(c.text for c in msg.contents if c.text): + user_message_count += 1 + if user_message_count == 2: # Second user text message + fork_target_message = msg + break + + assert fork_target_message is not None, "Could not find second user message to fork from" + fork_message_path = fork_target_message.path + fork_message_uid = fork_message_path.split("/")[-1] # Extract UID from path + print(f" Fork target message: {fork_message_path}") + print(f" Fork target index: {fork_target_message.index}") + print(f" Fork target UID: {fork_message_uid}") + + # Fork the thread at the second user message + print("\nForking thread at second user message...") + fork_response = api_client.fork_thread_from_message( + fork_message_path, title="Forked Thread via Endpoint" + ) + + forked_thread = fork_response.get("thread", {}) + forked_thread_path = forked_thread.get("path", "") + print(" ✓ Thread forked successfully") + print(f" Forked thread path: {forked_thread_path}") + + # Fetch the forked thread to get full data including parent reference + forked_thread_full = api_client.get_thread(forked_thread_path) + + # Verify forked thread has parent reference + parent_thread_id = ( + forked_thread_full.parent_thread_id + if hasattr(forked_thread_full, "parent_thread_id") + else None + ) + assert parent_thread_id, "Forked thread missing parent_thread_id" + assert parent_thread_id.path == thread.path, ( + f"Parent path mismatch: {parent_thread_id.path} != {thread.path}" + ) + assert parent_thread_id.fork_message_uid == fork_message_uid, ( + f"Fork message UID mismatch: {parent_thread_id.fork_message_uid} != {fork_message_uid}" + ) + print(" ✓ Forked thread has correct parent reference") + + # Get messages from forked thread + forked_messages = api_client.list_messages(forked_thread_path) + forked_message_count = len(forked_messages.results) + print(f"\nForked thread has {forked_message_count} messages") + + # Verify forked thread has messages up to but NOT including the fork point (non-inclusive) + # Fork point is message2 (index should match fork_target_message.index) + # Proto3 omits zero values in JSON, so None means index 0 + fork_point_index = fork_target_message.index if fork_target_message.index is not None else 0 + expected_max_index = fork_point_index - 1 + print(f" Fork point index: {fork_point_index}") + print(f" Expected max index in forked thread: {expected_max_index}") + + # Verify all messages in forked thread have index < fork point (non-inclusive) + for msg in forked_messages.results: + # Proto3 omits zero values in JSON, so None means index 0 + msg_index = msg.index if msg.index is not None else 0 + assert msg_index < fork_point_index, ( + f"Forked thread has message with index {msg_index} >= fork point {fork_point_index}" + ) + print(f" ✓ All forked messages have index < {fork_point_index}") + + # Verify only the first user message is in the forked thread (fork point is non-inclusive) + # Count user text messages in forked thread + forked_user_text_count = sum( + 1 + for msg in forked_messages.results + if msg.role == "LLM_MESSAGE_ROLE_USER" and any(c.text for c in msg.contents if c.text) + ) + assert forked_user_text_count == 1, ( + f"Forked thread should have 1 user text message, got {forked_user_text_count}" + ) + print(" ✓ Fork target message and messages after it not included in fork (non-inclusive)") + + # Verify fork target message is NOT in the forked thread (non-inclusive behavior) + # Note: Forked messages have new paths but preserve the original message UIDs + fork_message_in_forked = any( + msg.path.endswith(f"/messages/{fork_message_uid}") for msg in forked_messages.results + ) + assert not fork_message_in_forked, ( + f"Fork target message (UID: {fork_message_uid}) should NOT be in forked thread (non-inclusive)" + ) + print(" ✓ Fork target message correctly excluded from forked thread") + + # Verify original thread is unchanged + original_messages_after = api_client.list_messages(thread.path) + assert len(original_messages_after.results) == original_message_count, ( + "Original thread message count changed after fork" + ) + print(" ✓ Original thread unchanged after fork") + + # Test that forked thread can continue independently + print("\nTesting independent continuation of forked thread...") + + # Set up new collector for forked thread + forked_collector = StreamCollector() + forked_stream_thread = threading.Thread( + target=stream_events_background, + args=(api_client, forked_thread_path, forked_collector), + kwargs={"timeout": 300.0, "verbose": False}, + daemon=True, + ) + forked_stream_thread.start() + + if not forked_collector.stream_started.wait(timeout=5.0): + raise RuntimeError("Forked thread stream failed to start") + time.sleep(1) + + # Submit message to forked thread + fork_message_response = api_client.submit_user_message( + forked_thread_path, "Can you help me with something different?" + ) + print(f" ✓ Submitted message to forked thread: {fork_message_response.get('uid', '')}") + + # Wait for forked thread to respond + start_time = time.time() + while time.time() - start_time < max_wait: + if forked_collector.has_idle_state(): + break + time.sleep(1) + + if not forked_collector.has_idle_state(): + raise AssertionError("Forked thread did not respond") + + time.sleep(2) + print(" ✓ Forked thread responded independently") + + # Verify original thread still has the same messages + original_messages_final = api_client.list_messages(thread.path) + assert len(original_messages_final.results) == original_message_count, ( + "Original thread modified when forked thread was updated" + ) + print(" ✓ Original thread still unchanged") + + # ======================================================================== + # Test fork-of-fork: Fork the already-forked thread + # ======================================================================== + print("\n" + SEPARATOR) + print("Testing Fork-of-Fork (Nested Forking)") + print(SEPARATOR) + print("") + + # Get messages from the forked thread after it responded + forked_thread_messages_after_response = api_client.list_messages(forked_thread_path) + fork_of_fork_original_count = len(forked_thread_messages_after_response.results) + print(f"First forked thread now has {fork_of_fork_original_count} messages") + + # Find a message in the forked thread to fork from + # Let's fork from the user message we just added to the forked thread + fork_of_fork_target = None + for msg in forked_thread_messages_after_response.results: + if msg.role == "LLM_MESSAGE_ROLE_USER" and any( + "different" in c.text for c in msg.contents if c.text + ): + fork_of_fork_target = msg + break + + assert fork_of_fork_target is not None, "Could not find fork-of-fork target message" + fork_of_fork_path = fork_of_fork_target.path + fork_of_fork_uid = fork_of_fork_path.split("/")[-1] + fork_of_fork_index = fork_of_fork_target.index if fork_of_fork_target.index is not None else 0 + print(f" Fork-of-fork target message: {fork_of_fork_path}") + print(f" Fork-of-fork target index: {fork_of_fork_index}") + print(f" Fork-of-fork target UID: {fork_of_fork_uid}") + + # Fork the forked thread + print("\nCreating fork-of-fork...") + fork_of_fork_response = api_client.fork_thread_from_message( + fork_of_fork_path, title="Fork of Forked Thread" + ) + + fork_of_fork_thread = fork_of_fork_response.get("thread", {}) + fork_of_fork_thread_path = fork_of_fork_thread.get("path", "") + print(" ✓ Fork-of-fork created successfully") + print(f" Fork-of-fork thread path: {fork_of_fork_thread_path}") + + # Fetch the fork-of-fork to verify parent reference + fork_of_fork_full = api_client.get_thread(fork_of_fork_thread_path) + + # Verify fork-of-fork has parent reference pointing to the first fork + fork_of_fork_parent = ( + fork_of_fork_full.parent_thread_id + if hasattr(fork_of_fork_full, "parent_thread_id") + else None + ) + assert fork_of_fork_parent, "Fork-of-fork missing parent_thread_id" + assert fork_of_fork_parent.path == forked_thread_path, ( + f"Fork-of-fork parent path mismatch: {fork_of_fork_parent.path} != {forked_thread_path}" + ) + assert fork_of_fork_parent.fork_message_uid == fork_of_fork_uid, ( + f"Fork-of-fork message UID mismatch: {fork_of_fork_parent.fork_message_uid} != {fork_of_fork_uid}" + ) + print(" ✓ Fork-of-fork has correct parent reference (points to first fork)") + + # Get messages from fork-of-fork + fork_of_fork_messages = api_client.list_messages(fork_of_fork_thread_path) + fork_of_fork_message_count = len(fork_of_fork_messages.results) + print(f"\nFork-of-fork has {fork_of_fork_message_count} messages") + + # Verify fork-of-fork has messages up to but not including the fork point (non-inclusive) + for msg in fork_of_fork_messages.results: + msg_index = msg.index if msg.index is not None else 0 + assert msg_index < fork_of_fork_index, ( + f"Fork-of-fork has message with index {msg_index} >= fork point {fork_of_fork_index}" + ) + print(f" ✓ All fork-of-fork messages have index < {fork_of_fork_index}") + + # Verify fork-of-fork target message is NOT in the fork-of-fork (non-inclusive) + fork_of_fork_target_in_result = any( + msg.path.endswith(f"/messages/{fork_of_fork_uid}") for msg in fork_of_fork_messages.results + ) + assert not fork_of_fork_target_in_result, ( + f"Fork-of-fork target message should NOT be in fork-of-fork (non-inclusive)" + ) + print(" ✓ Fork-of-fork target message correctly excluded") + + # Verify that messages from the original thread that were copied to the first fork + # are also present in the fork-of-fork + # The first user message ("What is 2+2?") should be in all three threads + first_user_text = "What is 2+2?" + first_user_in_fork_of_fork = any( + first_user_text in " ".join(c.text for c in msg.contents if c.text) + for msg in fork_of_fork_messages.results + if msg.role == "LLM_MESSAGE_ROLE_USER" + ) + assert first_user_in_fork_of_fork, ( + f"First user message '{first_user_text}' should be in fork-of-fork" + ) + print(f" ✓ Messages from original thread correctly propagated to fork-of-fork") + + # Verify first forked thread is unchanged + forked_thread_after_fork_of_fork = api_client.list_messages(forked_thread_path) + assert len(forked_thread_after_fork_of_fork.results) == fork_of_fork_original_count, ( + "First forked thread modified when fork-of-fork was created" + ) + print(" ✓ First forked thread unchanged after fork-of-fork") + + print("") + print("Fork-of-Fork Summary:") + print(f" Original thread: {thread.path}") + print(f" First fork: {forked_thread_path}") + print(f" Fork-of-fork: {fork_of_fork_thread_path}") + print(f" Fork-of-fork messages: {fork_of_fork_message_count}") + print(f" Fork-of-fork parent: {fork_of_fork_parent.path}") + print(" Message propagation: ✓") + print(" Non-inclusive behavior: ✓") + print("") + + print("") + print(SEPARATOR) + print("METHOD 1: Fork via Endpoint - PASSED") + print(SEPARATOR) + print("") + print("Summary of Method 1 (ForkThreadFromMessage):") + print(f" Original thread: {thread.path}") + print(f" Original thread messages: {original_message_count}") + print(f" Fork point message: {fork_message_path}") + print(f" Fork point index: {fork_target_message.index}") + print(f" Forked thread: {forked_thread_path}") + print(f" Forked thread messages: {forked_message_count}") + print(" Parent thread reference: ✓") + print(f" Fork message UID: {parent_thread_id.fork_message_uid}") + print(" Non-inclusive behavior: ✓") + print(" Independent continuation: ✓") + print("") + print("Fork-of-Fork:") + print(f" Fork-of-fork thread: {fork_of_fork_thread_path}") + print(f" Fork-of-fork messages: {fork_of_fork_message_count}") + print(f" Parent (first fork): {fork_of_fork_parent.path}") + print(" Message propagation: ✓") + print(" Non-inclusive behavior: ✓") + print("") + + # ======================================================================== + # METHOD 2: Test forking via EditThreadMessage with create_fork=true + # ======================================================================== + + print("") + print(SEPARATOR) + print("METHOD 2: Testing Fork via EditThreadMessage") + print(SEPARATOR) + print("") + + # Submit fourth user message to original thread (to have more messages for edit test) + print("Submitting fourth user message to original thread...") + api_client.submit_user_message(thread.path, "Tell me about JavaScript.") + print(" ✓ Message 4 submitted") + + # Wait for fourth message to complete + print("\nWaiting for fourth message to complete...") + start_time = time.time() + while time.time() - start_time < max_wait: + if collector.count_idle_states() >= 4: + break + time.sleep(1) + + if collector.count_idle_states() < 4: + raise AssertionError("Thread did not return to IDLE after fourth message") + + time.sleep(2) + print(" ✓ Fourth message completed") + + # Get updated message count + original_messages_before_edit = api_client.list_messages(thread.path) + message_count_before_edit = len(original_messages_before_edit.results) + print(f"\nOriginal thread has {message_count_before_edit} messages before edit-fork") + + # Find the fourth user text message to edit + edit_target_message = None + user_message_count = 0 + for msg in original_messages_before_edit.results: + if msg.role == "LLM_MESSAGE_ROLE_USER" and any(c.text for c in msg.contents if c.text): + user_message_count += 1 + if user_message_count == 4: # Fourth user text message + edit_target_message = msg + break + + assert edit_target_message is not None, "Could not find fourth user message to edit" + edit_message_path = edit_target_message.path + edit_message_uid = edit_message_path.split("/")[-1] # Extract UID from path + print(f" Edit target message: {edit_message_path}") + print(f" Edit target index: {edit_target_message.index}") + print(f" Edit target UID: {edit_message_uid}") + + # Edit the message with create_fork=true + print("\nEditing message with create_fork=true...") + edit_response = api_client.edit_thread_message( + edit_message_path, "Tell me about Rust instead.", restore=False, create_fork=True + ) + + # Get forked thread from response + forked_thread_via_edit = edit_response.get("forkedThread", {}) + assert forked_thread_via_edit, "Edit response missing forkedThread field" + + forked_thread_via_edit_path = forked_thread_via_edit.get("path", "") + print(" ✓ Thread forked via edit successfully") + print(f" Forked thread path: {forked_thread_via_edit_path}") + + # Fetch the forked thread to get full data including parent reference + forked_thread_via_edit_full = api_client.get_thread(forked_thread_via_edit_path) + + # Verify forked thread has parent reference + parent_thread_id_edit = ( + forked_thread_via_edit_full.parent_thread_id + if hasattr(forked_thread_via_edit_full, "parent_thread_id") + else None + ) + assert parent_thread_id_edit, "Forked thread (via edit) missing parent_thread_id" + assert parent_thread_id_edit.path == thread.path, ( + f"Parent path mismatch: {parent_thread_id_edit.path} != {thread.path}" + ) + assert parent_thread_id_edit.fork_message_uid == edit_message_uid, ( + f"Fork message UID mismatch: {parent_thread_id_edit.fork_message_uid} != {edit_message_uid}" + ) + print(" ✓ Forked thread (via edit) has correct parent reference") + + # Get messages from forked thread via edit + forked_messages_via_edit = api_client.list_messages(forked_thread_via_edit_path) + forked_message_count_edit = len(forked_messages_via_edit.results) + print(f"\nForked thread (via edit) has {forked_message_count_edit} messages") + + # Find the edited message in forked thread + edited_message_in_fork = None + # Proto3 omits zero values in JSON, so None means index 0 + edit_target_index = edit_target_message.index if edit_target_message.index is not None else 0 + for msg in forked_messages_via_edit.results: + msg_index = msg.index if msg.index is not None else 0 + if msg_index == edit_target_index: + edited_message_in_fork = msg + break + + assert edited_message_in_fork is not None, "Edited message not found in forked thread" + + # Verify edited content + edited_content = " ".join(c.text for c in edited_message_in_fork.contents if c.text) + assert "Rust" in edited_content, f"Edited message should contain 'Rust', got: {edited_content}" + print(f" ✓ Edited message has new content: '{edited_content}'") + + # Verify original thread is unchanged + original_messages_after_edit_fork = api_client.list_messages(thread.path) + assert len(original_messages_after_edit_fork.results) == message_count_before_edit, ( + "Original thread message count changed after edit-fork" + ) + + # Verify original message4 content unchanged by finding it by path + original_message4 = None + for msg in original_messages_after_edit_fork.results: + if msg.path == edit_message_path: + original_message4 = msg + break + + assert original_message4 is not None, "Could not find original message4 after edit-fork" + original_content = " ".join(c.text for c in original_message4.contents if c.text) + assert "JavaScript" in original_content, ( + f"Original message should still contain 'JavaScript', got: {original_content}" + ) + print(" ✓ Original thread unchanged (original message content preserved)") + + # Test that forked thread (via edit) can continue independently + print("\nTesting independent continuation of forked thread (via edit)...") + + # Set up collector for forked thread via edit + forked_edit_collector = StreamCollector() + forked_edit_stream_thread = threading.Thread( + target=stream_events_background, + args=(api_client, forked_thread_via_edit_path, forked_edit_collector), + kwargs={"timeout": 300.0, "verbose": False}, + daemon=True, + ) + forked_edit_stream_thread.start() + + if not forked_edit_collector.stream_started.wait(timeout=5.0): + raise RuntimeError("Forked thread (via edit) stream failed to start") + time.sleep(1) + + # The forked thread should automatically get a response to the edited message + # Wait for it to process + start_time = time.time() + while time.time() - start_time < max_wait: + if forked_edit_collector.has_idle_state(): + break + time.sleep(1) + + if not forked_edit_collector.has_idle_state(): + raise AssertionError("Forked thread (via edit) did not respond to edited message") + + time.sleep(2) + print(" ✓ Forked thread (via edit) responded to edited message") + + # Verify original thread still unchanged + original_messages_final = api_client.list_messages(thread.path) + assert len(original_messages_final.results) == message_count_before_edit, ( + "Original thread modified when forked thread (via edit) was updated" + ) + print(" ✓ Original thread still unchanged") + + # Final summary + print("") + print(SEPARATOR) + print("Thread Forking Test PASSED") + print(SEPARATOR) + print("") + print("Overall Summary:") + print(f" Original thread: {thread.path}") + print(f" Original thread final messages: {len(original_messages_final.results)}") + print("") + print(" Method 1 (ForkThreadFromMessage):") + print(f" Fork point index: {fork_target_message.index}") + print(f" Forked thread: {forked_thread_path}") + print(f" Forked thread messages: {forked_message_count}") + print(f" Fork message UID: {parent_thread_id.fork_message_uid}") + print(" Non-inclusive behavior: ✓") + print("") + print(" Fork-of-Fork (Nested Forking):") + print(f" Fork-of-fork point index: {fork_of_fork_index}") + print(f" Fork-of-fork thread: {fork_of_fork_thread_path}") + print(f" Fork-of-fork messages: {fork_of_fork_message_count}") + print(f" Parent thread: {fork_of_fork_parent.path}") + print(" Message propagation: ✓") + print(" Non-inclusive behavior: ✓") + print("") + print(" Method 2 (EditThreadMessage with create_fork=true):") + print(f" Edit target index: {edit_target_message.index}") + print(f" Forked thread: {forked_thread_via_edit_path}") + print(f" Forked thread messages: {forked_message_count_edit}") + print(f" Fork message UID: {parent_thread_id_edit.fork_message_uid}") + print(" Edited content in fork: ✓") + print(" Original content preserved: ✓") + print("") + print(" All fork methods validated: ✓") + print(" Original thread unchanged: ✓") + print(" Independent continuation: ✓") + print(" Fork-of-fork tested: ✓") + print("") + + # Add to test summary + test_summary.extra_info["Original thread messages"] = str(len(original_messages_final.results)) + test_summary.extra_info["Fork method 1 (endpoint)"] = ( + f"Index {fork_target_message.index}, {forked_message_count} messages" + ) + test_summary.extra_info["Fork-of-fork"] = ( + f"Index {fork_of_fork_index}, {fork_of_fork_message_count} messages" + ) + test_summary.extra_info["Fork method 2 (edit)"] = ( + f"Index {edit_target_message.index}, {forked_message_count_edit} messages" + ) + test_summary.extra_info["Fork methods tested"] = ( + "ForkThreadFromMessage, EditThreadMessage, and Fork-of-Fork" + ) diff --git a/tim-api/internal/mapper/thread.go b/tim-api/internal/mapper/thread.go index a5924ccd9..ef33848d6 100644 --- a/tim-api/internal/mapper/thread.go +++ b/tim-api/internal/mapper/thread.go @@ -31,9 +31,21 @@ func ThreadToProto(thread db.GetThreadRow, path *resourcepath.ThreadPath) *threa Parent: path.Parent, ThreadUID: *thread.ParentUID, } - proto.ParentThreadId = &threadv1.Thread_ParentThreadId{ + parentThreadId := &threadv1.Thread_ParentThreadId{ Path: parentThreadPath.String(), } + + // Add fork message UID if present + if thread.ForkMessageUid != nil { + parentThreadId.ForkMessageUid = thread.ForkMessageUid.String() + } + + // Add before_time from fork message create time if present + if thread.ForkMessageCreateTime.Valid { + parentThreadId.BeforeTime = timestamppb.New(thread.ForkMessageCreateTime.Time) + } + + proto.ParentThreadId = parentThreadId } return proto diff --git a/tim-api/internal/services/thread/handlers.go b/tim-api/internal/services/thread/handlers.go index 6d8ce5a99..dc301f30b 100644 --- a/tim-api/internal/services/thread/handlers.go +++ b/tim-api/internal/services/thread/handlers.go @@ -20,6 +20,7 @@ import ( threadv1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/protobuf/types/known/timestamppb" ) // GetThread retrieves a thread by ID @@ -377,7 +378,7 @@ func (s *Service) CreateThread( return connect.NewResponse(threadProto), nil } -// ForkThread creates a new thread from an existing thread +// ForkThread creates a new thread forked at a specific message (inclusive) func (s *Service) ForkThread( ctx context.Context, req *connect.Request[threadv1.ForkThreadRequest], @@ -386,11 +387,14 @@ func (s *Service) ForkThread( if err != nil { return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } - originThreadPath, err := resourcepath.ParseThreadPath(req.Msg.Parent) + + messagePath, err := resourcepath.ParseThreadMessagePath(req.Msg.Message) if err != nil { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid path")) + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid message path")) } - authzHandle.SetResource(ResourceType, originThreadPath) + + // Authorize fork action on the parent thread + authzHandle.SetResource(ResourceType, messagePath.Parent) err = authzHandle.Authorize(ctx, "fork") if err != nil { return nil, err @@ -401,11 +405,36 @@ func (s *Service) ForkThread( s.logger.Errorw("failed to get database queries", "error", err) return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } - // Check parent thread exists + + // Get the message to fork from + message, err := queries.GetThreadMessage(ctx, db.GetThreadMessageParams{ + MessageUID: messagePath.ThreadMessageUID, + ThreadUID: messagePath.Parent.ThreadUID, + }) + 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 allow forking at user messages + if message.Role != db.LlmMessageRoleUser { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("can only fork at user messages")) + } + + // For user messages, we fork *before* the message (non-inclusive) + // This preserves all previous conversation but allows rewriting from this point + if message.Idx == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("cannot fork at the first message")) + } + + // Get parent thread parentThread, err := queries.GetThread(ctx, db.GetThreadParams{ - ThreadUID: originThreadPath.ThreadUID, - OrganizationUID: *originThreadPath.OwnerOrg().OrgUID, - OwnerUID: *originThreadPath.Owner().UserUID, + ThreadUID: messagePath.Parent.ThreadUID, + OrganizationUID: *messagePath.Parent.OwnerOrg().OrgUID, + OwnerUID: *messagePath.Parent.Owner().UserUID, }) if err != nil { s.logger.Errorw("failed to get parent thread", "error", err) @@ -415,55 +444,92 @@ func (s *Service) ForkThread( return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } - // Create new thread with parent reference - thread, err := queries.CreateThread(ctx, db.CreateThreadParams{ + // Determine title for forked thread + title := fmt.Sprintf("Fork of %s", parentThread.Title) + if req.Msg.Title != nil && *req.Msg.Title != "" { + title = *req.Msg.Title + } + + // Create new thread with parent reference and fork message + forkedThread, err := queries.CreateThread(ctx, db.CreateThreadParams{ ParentUID: &parentThread.UID, + ForkMessageUid: &message.UID, OwnerUID: parentThread.OwnerUID, OrganizationUID: parentThread.OrganizationUID, PersonaRevisionUID: parentThread.PersonaRevisionUID, - Title: fmt.Sprintf("Fork of %s", parentThread.Title), + Title: title, }) if err != nil { s.logger.Errorw("failed to create forked thread", "error", err) return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } - path := &resourcepath.ThreadPath{ - Parent: originThreadPath.Parent, - ThreadUID: thread.UID, + + // Copy messages up to but NOT including the fork message (message.Idx - 1) + // This preserves all conversation before the selected user message + maxIdxToCopy := message.Idx - 1 + s.logger.Infow( + "Copying messages to forked thread", + "fork_message_uid", message.UID, + "fork_message_idx", message.Idx, + "max_idx_to_copy", maxIdxToCopy, + "parent_thread_uid", parentThread.UID, + "forked_thread_uid", forkedThread.UID, + ) + err = queries.CopyThreadMessagesUpToAndIncludingIndex(ctx, db.CopyThreadMessagesUpToAndIncludingIndexParams{ + NewThreadUid: forkedThread.UID, + SourceThreadUid: parentThread.UID, + MaxIdx: maxIdxToCopy, + }) + if err != nil { + s.logger.Errorw("failed to copy messages to forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Count how many messages were copied for debugging + copiedCount, err := queries.CountThreadMessages(ctx, forkedThread.UID) + if err != nil { + s.logger.Warnw("failed to count copied messages", "error", err) + } else { + s.logger.Infow("Messages copied to forked thread", "count", copiedCount, "max_idx_copied", maxIdxToCopy) } - // TODO: Copy messages from parent thread before the specified time - // This would require querying messages and copying them to the new thread + path := &resourcepath.ThreadPath{ + Parent: messagePath.Parent.Parent, + ThreadUID: forkedThread.UID, + } // Fetch the thread with persona_uid for the response threadRow, err := queries.GetThread(ctx, db.GetThreadParams{ - ThreadUID: thread.UID, - OrganizationUID: thread.OrganizationUID, - OwnerUID: thread.OwnerUID, + ThreadUID: forkedThread.UID, + OrganizationUID: forkedThread.OrganizationUID, + OwnerUID: forkedThread.OwnerUID, }) if err != nil { - s.logger.Errorw("failed to get thread", "error", err) + s.logger.Errorw("failed to get forked thread", "error", err) return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) } threadProto := mapper.ThreadToProto(threadRow, path) - // Set parent thread info with before time + // Set parent thread info with fork message threadProto.ParentThreadId = &threadv1.Thread_ParentThreadId{ - Path: req.Msg.Parent, - BeforeTime: req.Msg.BeforeTime, + Path: messagePath.Parent.String(), + BeforeTime: timestamppb.New(message.CreateTime.Time), + ForkMessageUid: message.UID.String(), } s.analytics.ThreadForked( parentThread.OwnerUID, - thread.UID, + forkedThread.UID, parentThread.UID, - thread.CreateTime.Time, + forkedThread.CreateTime.Time, ) s.logger.Infow( - "Thread forked", - "path", path, - "parent", req.Msg.Parent, + "Thread forked from message", + "forked_thread", path, + "parent_thread", messagePath.Parent.String(), + "fork_message", message.UID.String(), + "fork_message_idx", message.Idx, ) return connect.NewResponse(&threadv1.ForkThreadResponse{ diff --git a/tim-api/internal/services/thread/message_handlers.go b/tim-api/internal/services/thread/message_handlers.go index e0e1c61bd..907862b28 100644 --- a/tim-api/internal/services/thread/message_handlers.go +++ b/tim-api/internal/services/thread/message_handlers.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const ( @@ -74,48 +75,186 @@ 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 (for restoration if requested) - checkpoint, err := queries.GetCheckpointByMessage(ctx, messagePath.ThreadMessageUID) - checkpointExists := err == nil + // Check if create_fork is requested + shouldFork := req.Msg.CreateFork != nil && *req.Msg.CreateFork - // 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")) - } + var forkedThreadProto *threadv1.Thread + var updatedMessagePath *resourcepath.ThreadMessagePath + var targetThreadUID uuid.UUID - // 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")) - } + if shouldFork { + // FORK PATH: Create a new thread and add the edited message there + // Original thread remains unchanged - // 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")) - } + // Get the parent thread + parentThread, err := queries.GetThread(ctx, db.GetThreadParams{ + ThreadUID: messagePath.Parent.ThreadUID, + OrganizationUID: *messagePath.Parent.OwnerOrg().OrgUID, + OwnerUID: *messagePath.Parent.Owner().UserUID, + }) + if err != nil { + s.logger.Errorw("failed to get parent thread for fork", "error", err) + if errors.Is(err, sql.ErrNoRows) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("parent thread not found")) + } + 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")) + // Create forked thread + forkedThread, err := queries.CreateThread(ctx, db.CreateThreadParams{ + ParentUID: &parentThread.UID, + ForkMessageUid: &message.UID, + OwnerUID: parentThread.OwnerUID, + OrganizationUID: parentThread.OrganizationUID, + PersonaRevisionUID: parentThread.PersonaRevisionUID, + Title: fmt.Sprintf("Fork of %s", parentThread.Title), + }) + if err != nil { + s.logger.Errorw("failed to create forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Copy messages BEFORE the edited message (exclude the edited message itself) + if message.Idx > 0 { + err = queries.CopyThreadMessagesUpToAndIncludingIndex(ctx, db.CopyThreadMessagesUpToAndIncludingIndexParams{ + NewThreadUid: forkedThread.UID, + SourceThreadUid: parentThread.UID, + MaxIdx: message.Idx - 1, + }) + if err != nil { + s.logger.Errorw("failed to copy messages to forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + } + + // Create new user message with edited content in forked thread + newMessage, err := queries.CreateMessage(ctx, db.CreateMessageParams{ + OriginThreadUID: forkedThread.UID, + Idx: message.Idx, + Role: db.LlmMessageRoleUser, + StreamStatus: db.LlmMessageStreamStatusComplete, + }) + if err != nil { + s.logger.Errorw("failed to create new message in forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Add message to forked thread + err = queries.AddMessageToThread(ctx, db.AddMessageToThreadParams{ + ThreadUID: forkedThread.UID, + MessageUID: newMessage.UID, + }) + if err != nil { + s.logger.Errorw("failed to link new message to forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + // Create text content for the new message + 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.CreateMessageContent(ctx, db.CreateMessageContentParams{ + UID: uuid.New(), + MessageUID: newMessage.UID, + Idx: 0, + Type: db.LlmMessageContentTypeText, + Content: contentJSON, + StreamStatus: db.LlmMessageStreamStatusComplete, + }) + if err != nil { + s.logger.Errorw("failed to create message content", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + targetThreadUID = forkedThread.UID + updatedMessagePath = &resourcepath.ThreadMessagePath{ + Parent: &resourcepath.ThreadPath{ + Parent: messagePath.Parent.Parent, + ThreadUID: forkedThread.UID, + }, + ThreadMessageUID: newMessage.UID, + } + + // Build forked thread proto for response + forkedThreadPath := &resourcepath.ThreadPath{ + Parent: messagePath.Parent.Parent, + ThreadUID: forkedThread.UID, + } + threadRow, err := queries.GetThread(ctx, db.GetThreadParams{ + ThreadUID: forkedThread.UID, + OrganizationUID: forkedThread.OrganizationUID, + OwnerUID: forkedThread.OwnerUID, + }) + if err != nil { + s.logger.Errorw("failed to get forked thread", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("internal server error")) + } + + forkedThreadProto = mapper.ThreadToProto(threadRow, forkedThreadPath) + forkedThreadProto.ParentThreadId = &threadv1.Thread_ParentThreadId{ + Path: messagePath.Parent.String(), + BeforeTime: timestamppb.New(message.CreateTime.Time), + ForkMessageUid: message.UID.String(), + } + + s.logger.Infow("Thread forked via message edit", + "original_thread", messagePath.Parent.String(), + "forked_thread", forkedThreadPath.String(), + "fork_message", message.UID.String()) + + } else { + // EDIT-IN-PLACE PATH: Modify the original thread + // 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")) + } + + targetThreadUID = message.OriginThreadUID + updatedMessagePath = messagePath } + // Check if there's a checkpoint at this message index (for restoration if requested) + checkpoint, err := queries.GetCheckpointByMessage(ctx, messagePath.ThreadMessageUID) + checkpointExists := err == nil + // 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) @@ -188,7 +327,7 @@ func (s *Service) EditThreadMessage( // Update thread status to processing to trigger LLM response err = queries.UpdateThreadLLMStatus(ctx, db.UpdateThreadLLMStatusParams{ - ThreadUID: message.OriginThreadUID, + ThreadUID: targetThreadUID, LlmStatus: db.ThreadLlmStatusProcessing, }) if err != nil { @@ -197,12 +336,12 @@ func (s *Service) EditThreadMessage( } // Notify about thread state change - notifier := natsnotifier.NewThreadEventNotifier(ctx, s.nats, messagePath.Parent, s.logger) + notifier := natsnotifier.NewThreadEventNotifier(ctx, s.nats, updatedMessagePath.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() + threadPath := updatedMessagePath.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) @@ -214,7 +353,7 @@ func (s *Service) EditThreadMessage( // Get updated message to return updatedMessage, err := s.GetLlmMessage(ctx, connect.NewRequest(&threadv1.GetLlmMessageRequest{ - Path: req.Msg.Path, + Path: updatedMessagePath.String(), })) if err != nil { s.logger.Errorw("failed to get updated message", "error", err) @@ -224,6 +363,7 @@ func (s *Service) EditThreadMessage( response := &threadv1.EditThreadMessageResponse{ Message: updatedMessage.Msg, FileRestorations: fileRestorations, + ForkedThread: forkedThreadProto, } s.logger.Infow("Message edited successfully, LLM relay job enqueued", diff --git a/tim-cli-v2/internal/client/client.go b/tim-cli-v2/internal/client/client.go index 1a518aa26..58e05e1ce 100644 --- a/tim-cli-v2/internal/client/client.go +++ b/tim-cli-v2/internal/client/client.go @@ -264,6 +264,24 @@ func (c *TimAPIClient) ListPersonas(ctx context.Context) (*personav1alpha1.ListP return resp, nil } +// ForkThread creates a forked thread at the specified message path +func (c *TimAPIClient) ForkThread(ctx context.Context, messagePath string, title *string) (*threadv1alpha1.Thread, error) { + c.debugLog("[client] ForkThread: messagePath=%s", messagePath) + req := connect.NewRequest(&threadv1alpha1.ForkThreadRequest{ + Message: messagePath, + }) + if title != nil { + req.Msg.Title = title + } + resp, err := c.client.Thread.ForkThread(ctx, req) + if err != nil { + c.debugLog("[client] ForkThread failed: %v", err) + return nil, err + } + c.debugLog("[client] ForkThread successful: path=%s", resp.Msg.Thread.GetPath()) + return resp.Msg.Thread, nil +} + // EditThreadMessage edits a message on a thread. // // If restore is set to true and a checkpoint exists, the API will return file_restorations @@ -272,12 +290,14 @@ func (c *TimAPIClient) ListPersonas(ctx context.Context) (*personav1alpha1.ListP // 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) + +func (c *TimAPIClient) EditThreadMessage(ctx context.Context, messagePath, newContent string, restore *bool, createFork *bool) (*threadv1alpha1.EditThreadMessageResponse, error) { + c.debugLog("[client] EditThreadMessage: messagePath=%s, contentLen=%d, restore=%v, createFork=%v", messagePath, len(newContent), restore, createFork) req := connect.NewRequest(&threadv1alpha1.EditThreadMessageRequest{ - Path: messagePath, - Content: newContent, - Restore: restore, + Path: messagePath, + Content: newContent, + Restore: restore, + CreateFork: createFork, }) resp, err := c.client.Thread.EditThreadMessage(ctx, req) if err != nil { diff --git a/tim-cli-v2/internal/tui/model.go b/tim-cli-v2/internal/tui/model.go index 6ecda6975..b55a559e7 100644 --- a/tim-cli-v2/internal/tui/model.go +++ b/tim-cli-v2/internal/tui/model.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io/fs" "log" @@ -41,6 +42,7 @@ type ViewMode int const ( ViewModeChat ViewMode = iota + ViewModeThreadList ViewModeMessageList ViewModeEditing ViewModeConfirmRestore @@ -164,6 +166,7 @@ type ( messageEditedMsg struct { filesRestored int filesDeleted int + forkedThread *threadv1alpha1.Thread } // restoreRequiredMsg is sent when checkpoint restoration is required @@ -172,13 +175,24 @@ type ( content string origContent string } + + // threadsLoadedMsg is sent when threads are fetched for the thread selector + threadsLoadedMsg struct { + threads []*threadv1alpha1.Thread + } + + // threadForkedMsg is sent when a new forked thread is created + threadForkedMsg struct { + thread *threadv1alpha1.Thread + } ) // Model represents the chat TUI state type Model struct { - client *client.TimAPIClient - personaUID string - threadID string + client *client.TimAPIClient + personaUID string + threadID string + threadDisplayName string viewport viewport.Model textarea textarea.Model @@ -204,11 +218,13 @@ type Model struct { editingMessageOrig string // Original content before editing editingMessage *threadv1alpha1.LlmMessage // Full message being edited (for checkpoint check) messageListViewport viewport.Model // Viewport for message list + editingCreateFork bool // True when editing flow should create a fork // 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 + restoreConfirmIsFork bool // True when confirmation is for a fork operation // Clarify tool state awaitingClarification bool // True when waiting for user's clarification answer @@ -226,11 +242,18 @@ type Model struct { baseDelay time.Duration // Base delay for exponential backoff (e.g., 1s) maxDelay time.Duration // Maximum delay cap (e.g., 30s) - ctx context.Context - cancel context.CancelFunc + ctx context.Context + cancel context.CancelFunc + streamCancel context.CancelFunc + suppressNextStreamError bool // Debug logging debugLogger *log.Logger + + // Thread selector state + threads []*threadv1alpha1.Thread + selectedThreadIdx int + threadListViewport viewport.Model } // NewModel creates a new chat TUI model @@ -250,6 +273,7 @@ func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string vp := viewport.New(80, 20) msgListVp := viewport.New(80, 20) + threadListVp := viewport.New(80, 20) ctx, cancel := context.WithCancel(context.Background()) @@ -279,27 +303,33 @@ func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string }) return Model{ - client: client, - personaUID: personaUID, - threadID: threadID, // Can be empty string for new thread - textarea: ta, - viewport: vp, - messages: []string{}, - currentResponse: &strings.Builder{}, // Initialize as pointer - displayedContentIDs: make(map[string]bool), - viewMode: ViewModeChat, - llmMessages: []*threadv1alpha1.LlmMessage{}, - selectedMessageIdx: 0, - messageListViewport: msgListVp, - localExecutor: localExecutor, - toolLogger: toolLogger, - executingToolsMap: make(map[string]int), - retryCount: 0, - baseDelay: 1 * time.Second, - maxDelay: 30 * time.Second, - ctx: ctx, - cancel: cancel, - debugLogger: debugLogger, + client: client, + personaUID: personaUID, + threadID: threadID, // Can be empty string for new thread + threadDisplayName: "", + textarea: ta, + viewport: vp, + messages: []string{}, + currentResponse: &strings.Builder{}, // Initialize as pointer + displayedContentIDs: make(map[string]bool), + viewMode: ViewModeChat, + llmMessages: []*threadv1alpha1.LlmMessage{}, + selectedMessageIdx: 0, + messageListViewport: msgListVp, + localExecutor: localExecutor, + toolLogger: toolLogger, + executingToolsMap: make(map[string]int), + retryCount: 0, + baseDelay: 1 * time.Second, + maxDelay: 30 * time.Second, + ctx: ctx, + cancel: cancel, + debugLogger: debugLogger, + threads: []*threadv1alpha1.Thread{}, + selectedThreadIdx: 0, + threadListViewport: threadListVp, + editingCreateFork: false, + restoreConfirmIsFork: false, } } @@ -373,6 +403,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( tiCmd tea.Cmd vpCmd tea.Cmd + mlCmd tea.Cmd + tlCmd tea.Cmd ) // Handle keyboard input - check for special keys before updating textarea @@ -388,15 +420,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { messagePath := m.restoreConfirmMessagePath content := m.restoreConfirmContent origContent := m.restoreConfirmOrigContent + createFork := m.restoreConfirmIsFork // Clear confirmation state m.restoreConfirmMessagePath = "" m.restoreConfirmContent = "" m.restoreConfirmOrigContent = "" + m.restoreConfirmIsFork = false // Clear editing state m.editingMessagePath = "" m.editingMessageOrig = "" m.editingMessage = nil + m.editingCreateFork = false // Switch back to chat view m.viewMode = ViewModeChat @@ -411,7 +446,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Save with restoration (restore = true) restoreTrue := true - return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreTrue) + return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreTrue, createFork) case "n", "N": // User declined restoration - save without restoring (new checkpoint replaces old) @@ -419,15 +454,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { messagePath := m.restoreConfirmMessagePath content := m.restoreConfirmContent origContent := m.restoreConfirmOrigContent + createFork := m.restoreConfirmIsFork // Clear confirmation state m.restoreConfirmMessagePath = "" m.restoreConfirmContent = "" m.restoreConfirmOrigContent = "" + m.restoreConfirmIsFork = false // Clear editing state m.editingMessagePath = "" m.editingMessageOrig = "" m.editingMessage = nil + m.editingCreateFork = false // Switch back to chat view m.viewMode = ViewModeChat @@ -442,7 +480,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Save without restoration (restore = false) restoreFalse := false - return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreFalse) + return m, m.saveEditedMessageAsync(messagePath, origContent, content, &restoreFalse, createFork) case "esc": // User cancelled - return to message list @@ -450,10 +488,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.restoreConfirmMessagePath = "" m.restoreConfirmContent = "" m.restoreConfirmOrigContent = "" + m.restoreConfirmIsFork = false // Clear editing state m.editingMessagePath = "" m.editingMessageOrig = "" m.editingMessage = nil + m.editingCreateFork = false // Reset textarea m.textarea.Blur() @@ -476,6 +516,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Clear editing state m.editingMessagePath = "" m.editingMessageOrig = "" + m.editingMessage = nil + m.editingCreateFork = false // Switch back to message list m.viewMode = ViewModeMessageList @@ -483,6 +525,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.Reset() return m, nil + case tea.KeyCtrlF: + m.debugLog("Ctrl+F pressed while editing, creating fork") + + newContent := strings.TrimSpace(m.textarea.Value()) + if newContent == "" { + m.debugLog("Ctrl+F ignored due to empty content") + return m, nil + } + + messagePath := m.editingMessagePath + origContent := m.editingMessageOrig + editingMessage := m.editingMessage + + m.editingCreateFork = true + + if editingMessage != nil && editingMessage.HasCheckpoint { + m.debugLog("Forking edit requires checkpoint confirmation") + m.restoreConfirmMessagePath = messagePath + m.restoreConfirmContent = newContent + m.restoreConfirmOrigContent = origContent + m.restoreConfirmIsFork = true + m.viewMode = ViewModeConfirmRestore + return m, nil + } + + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + + m.viewMode = ViewModeChat + m.textarea.Blur() + m.textarea.Reset() + m.textarea.Focus() + + m.addMessage("Forking message into new thread...", "system") + m.updateViewport() + + cmd := m.saveEditedMessageAsync(messagePath, origContent, newContent, nil, true) + m.editingCreateFork = false + return m, cmd + case tea.KeyEnter: // Check if Alt is held (Alt+Enter should add a new line) if keyMsg.Alt { @@ -497,15 +580,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { messagePath := m.editingMessagePath origContent := m.editingMessageOrig editingMessage := m.editingMessage + m.editingCreateFork = false - // 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.restoreConfirmIsFork = false m.viewMode = ViewModeConfirmRestore return m, nil } @@ -513,25 +595,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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) + return m, m.saveEditedMessageAsync(messagePath, origContent, newContent, nil, false) } } } @@ -539,6 +615,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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) + m.messageListViewport, mlCmd = m.messageListViewport.Update(msg) + m.threadListViewport, tlCmd = m.threadListViewport.Update(msg) switch msg := msg.(type) { case tea.KeyMsg: @@ -568,6 +646,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.debugLog("Ctrl+L pressed, switching to message viewer") return m, m.loadMessages() } + + case tea.KeyCtrlT: + m.debugLog("Ctrl+T pressed, switching to thread selector") + m.viewMode = ViewModeThreadList + m.textarea.Blur() + m.threadListViewport.SetContent("Loading threads...") + return m, m.loadThreads() } case ViewModeMessageList: @@ -580,10 +665,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - // Move up to the next selectable message + // Move up to the next user message + if len(m.llmMessages) == 0 { + return m, nil + } newIdx := m.selectedMessageIdx - 1 for newIdx >= 0 { - if m.isSelectableMessage(m.llmMessages[newIdx]) { + if m.llmMessages[newIdx].Role == threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER { m.selectedMessageIdx = newIdx m.updateMessageListViewport() break @@ -593,10 +681,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyDown: - // Move down to the next selectable message + // Move down to the next user message + if len(m.llmMessages) == 0 { + return m, nil + } newIdx := m.selectedMessageIdx + 1 for newIdx < len(m.llmMessages) { - if m.isSelectableMessage(m.llmMessages[newIdx]) { + if m.llmMessages[newIdx].Role == threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER { m.selectedMessageIdx = newIdx m.updateMessageListViewport() break @@ -606,15 +697,93 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyEnter: - // Edit the selected message (only if it's selectable) + // Edit the selected message (cursor is always on a user message) if m.selectedMessageIdx < len(m.llmMessages) { msg := m.llmMessages[m.selectedMessageIdx] - 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) + m.debugLog("Enter pressed on message %d, starting edit", m.selectedMessageIdx) + return m, m.startEditingMessage(msg) + } + return m, nil + + case tea.KeyRunes: + input := strings.ToLower(msg.String()) + if input == "f" { + if len(m.llmMessages) == 0 || m.selectedMessageIdx >= len(m.llmMessages) { + return m, nil + } + selectedMessage := m.llmMessages[m.selectedMessageIdx] + + // Only allow forking at user messages + if selectedMessage.Role != threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER { + m.debugLog("Fork command ignored for non-user message %d", m.selectedMessageIdx) + return m, nil } + + // Don't allow forking at the first message + if selectedMessage.Index == 0 { + m.debugLog("Fork command ignored for first message") + return m, nil + } + + m.viewMode = ViewModeChat + m.textarea.Focus() + + forkMsg := fmt.Sprintf("Forking new thread before message %d...", selectedMessage.Index) + m.addMessage(forkMsg, "system") + m.updateViewport() + + return m, m.forkThreadFromMessage(selectedMessage) + } + return m, nil + } + + case ViewModeThreadList: + switch msg.Type { + case tea.KeyEsc: + m.debugLog("Esc pressed in thread list, returning to chat") + m.viewMode = ViewModeChat + m.textarea.Focus() + return m, nil + + case tea.KeyUp: + if len(m.threads) == 0 { + return m, nil + } + if m.selectedThreadIdx > 0 { + m.selectedThreadIdx-- + m.updateThreadListViewport() + } + return m, nil + + case tea.KeyDown: + if len(m.threads) == 0 { + return m, nil + } + if m.selectedThreadIdx < len(m.threads)-1 { + m.selectedThreadIdx++ + m.updateThreadListViewport() + } + return m, nil + + case tea.KeyEnter: + if len(m.threads) == 0 { + return m, nil + } + selectedThread := m.threads[m.selectedThreadIdx] + selectedID := client.ExtractThreadIDFromPath(selectedThread.Path) + if selectedID != "" && selectedID == m.threadID { + m.debugLog("Selected thread matches current thread, returning to chat") + m.viewMode = ViewModeChat + m.textarea.Focus() + return m, nil + } + return m, m.activateThread(selectedThread, "") + + case tea.KeyRunes: + if strings.ToLower(msg.String()) == "r" { + m.debugLog("Reloading thread list") + m.threadListViewport.SetContent("Reloading threads...") + return m, m.loadThreads() } return m, nil } @@ -644,12 +813,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update textarea size m.textarea.SetWidth(msg.Width - 4) + // Update auxiliary viewports + m.messageListViewport.Width = msg.Width - 4 + m.messageListViewport.Height = msg.Height - headerHeight - 2 + m.threadListViewport.Width = msg.Width - 4 + m.threadListViewport.Height = msg.Height - headerHeight - 2 + m.updateViewport() case threadCreatedMsg: m.debugLog("threadCreatedMsg: threadID=%s", msg.threadID) // Thread was just created - update ID and start streaming m.threadID = msg.threadID + m.threadDisplayName = "" threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) m.debugLog("Building thread path: %s", threadPath) // Open stream once for the entire session @@ -874,6 +1050,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Stream ended (timeout/disconnect) - reopen it to maintain persistent connection m.streamingResponse = false m.streamSub = nil + m.streamCancel = nil // DON'T clear displayedContentIDs - keep track of what we've seen // This prevents replayed content from showing again after reconnect @@ -919,6 +1096,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewport() case errMsg: + if m.suppressNextStreamError { + if errors.Is(msg.err, context.Canceled) || strings.Contains(msg.err.Error(), "context canceled") { + m.debugLog("Suppressed expected stream cancellation error: %v", msg.err) + m.suppressNextStreamError = false + return m, nil + } + m.suppressNextStreamError = false + } m.debugLog("errMsg: error=%v", msg.err) // If it's a stream error, try to reconnect @@ -926,6 +1111,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.debugLog("errMsg: stream error detected, attempting reconnect (attempt %d)", m.retryCount) m.streamingResponse = false m.streamSub = nil + m.streamCancel = nil m.err = nil // Clear error // DON'T clear displayedContentIDs - this prevents replay duplicates @@ -965,6 +1151,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addMessage(fmt.Sprintf("Error: %v", msg.err), "error") m.updateViewport() + case threadsLoadedMsg: + m.debugLog("threadsLoadedMsg: loaded %d threads", len(msg.threads)) + m.threads = msg.threads + m.viewMode = ViewModeThreadList + m.selectedThreadIdx = 0 + + if len(m.threads) > 0 { + if m.threadID != "" { + currentID := m.threadID + for i, thread := range m.threads { + if client.ExtractThreadIDFromPath(thread.Path) == currentID { + m.selectedThreadIdx = i + if thread.DisplayName != "" { + m.threadDisplayName = thread.DisplayName + } + break + } + } + } + m.updateThreadListViewport() + } else { + m.threadListViewport.SetContent("No threads found.") + } + m.threadListViewport.GotoTop() + return m, nil + case messagesLoadedMsg: m.debugLog("messagesLoadedMsg: loaded %d messages", len(msg.messages)) m.llmMessages = msg.messages @@ -991,9 +1203,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messageEditedMsg: 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 msg.forkedThread != nil { + statusParts := []string{"Forked from edited message."} + if totalChanged > 0 { + fileParts := []string{} + if msg.filesRestored > 0 { + fileParts = append(fileParts, fmt.Sprintf("%d restored", msg.filesRestored)) + } + if msg.filesDeleted > 0 { + fileParts = append(fileParts, fmt.Sprintf("%d deleted", msg.filesDeleted)) + } + statusParts = append(statusParts, fmt.Sprintf("Files changed: %s.", strings.Join(fileParts, ", "))) + } + statusParts = append(statusParts, "Waiting for LLM response...") + status := strings.Join(statusParts, " ") + return m, m.activateThread(msg.forkedThread, status) + } + if totalChanged > 0 { parts := []string{} if msg.filesRestored > 0 { @@ -1007,8 +1235,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addMessage("Message edited successfully. Waiting for LLM response...", "system") } m.updateViewport() - // Stream is already open and listening, new response will arrive automatically - // No need to reload messages - just wait for streaming events + + case threadForkedMsg: + if msg.thread == nil { + return m, nil + } + m.debugLog("threadForkedMsg: switching to new forked thread %s", msg.thread.Path) + return m, m.activateThread(msg.thread, "Fork created from selected message. Waiting for LLM response...") case restoreRequiredMsg: m.debugLog("restoreRequiredMsg: checkpoint restoration required for message") @@ -1020,7 +1253,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewMode = ViewModeConfirmRestore } - return m, tea.Batch(tiCmd, vpCmd) + return m, tea.Batch(tiCmd, vpCmd, mlCmd, tlCmd) } // View renders the TUI @@ -1032,6 +1265,8 @@ func (m Model) View() string { switch m.viewMode { case ViewModeChat: return m.viewChat() + case ViewModeThreadList: + return m.viewThreadList() case ViewModeMessageList: return m.viewMessageList() case ViewModeEditing: @@ -1051,7 +1286,13 @@ func (m Model) viewChat() string { title := titleStyle.Render("Tim CLI v2 - Interactive Chat") b.WriteString(title) if m.threadID != "" { - threadInfo := helpStyle.Render(fmt.Sprintf(" (Thread: %s)", m.threadID)) + label := m.threadDisplayName + if label == "" { + label = m.threadID + } else { + label = fmt.Sprintf("%s • %s", label, m.threadID) + } + threadInfo := helpStyle.Render(fmt.Sprintf(" (Thread: %s)", label)) b.WriteString(threadInfo) } b.WriteString("\n") @@ -1067,7 +1308,7 @@ func (m Model) viewChat() string { b.WriteString("\n") // Help text - help := helpStyle.Render("Enter: send • Ctrl+L: list messages • Esc: quit") + help := helpStyle.Render("Enter: send • Ctrl+L: messages • Ctrl+T: threads • Esc: quit") if m.awaitingClarification { help = helpStyle.Render("Enter: submit answer • Esc: quit") } else if m.waiting { @@ -1094,7 +1335,29 @@ func (m Model) viewMessageList() string { b.WriteString("\n") // Help text - help := helpStyle.Render("↑/↓: navigate • Enter: edit • Esc: back to chat") + help := helpStyle.Render("↑/↓: navigate • Enter: edit • f: fork at message (non-inclusive) • Esc: back to chat") + b.WriteString(help) + + return b.String() +} + +// viewThreadList renders the thread selector view +func (m Model) viewThreadList() string { + var b strings.Builder + + // Header + title := titleStyle.Render("Thread List - Select a thread") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n") + + // Thread list viewport + b.WriteString(m.threadListViewport.View()) + b.WriteString("\n") + + // Help text + help := helpStyle.Render("↑/↓: navigate • Enter: switch • r: reload • Esc: back to chat") b.WriteString(help) return b.String() @@ -1121,7 +1384,7 @@ func (m Model) viewEditing() string { b.WriteString("\n\n") // Help text - help := helpStyle.Render("Enter: save • Alt+Enter: new line • Esc: cancel") + help := helpStyle.Render("Enter: save • Ctrl+F: fork • Alt+Enter: new line • Esc: cancel") b.WriteString(help) return b.String() @@ -1151,17 +1414,21 @@ func (m Model) viewConfirmRestore() string { 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", - ) + explanationText := "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" + + if m.restoreConfirmIsFork { + explanationText += "\n\nForking will create a new thread starting from this message. The original thread remains unchanged." + } + + explanation := explanationStyle.Render(explanationText) b.WriteString(explanation) b.WriteString("\n\n") @@ -1175,7 +1442,11 @@ func (m Model) viewConfirmRestore() string { b.WriteString("\n\n") // Help text with three distinct options - help := helpStyle.Render("Y: restore and save • N: save without restoring • Esc: cancel") + helpText := "Y: restore and save • N: save without restoring • Esc: cancel" + if m.restoreConfirmIsFork { + helpText = "Y: restore and fork • N: fork without restoring • Esc: cancel" + } + help := helpStyle.Render(helpText) b.WriteString(help) return b.String() @@ -1314,12 +1585,22 @@ func (m *Model) streamResponse(query string) tea.Cmd { func (m *Model) listenToStream(threadPath string) tea.Cmd { return func() tea.Msg { m.debugLog("listenToStream: opening stream for path=%s", threadPath) - stream, err := m.client.StreamThreadEvents(m.ctx, threadPath) + if m.streamCancel != nil { + m.debugLog("listenToStream: cancelling existing stream before opening new one") + m.suppressNextStreamError = true + m.streamCancel() + m.streamCancel = nil + } + + streamCtx, cancel := context.WithCancel(m.ctx) + stream, err := m.client.StreamThreadEvents(streamCtx, threadPath) if err != nil { m.debugLog("listenToStream: failed to open stream: %v", err) + cancel() return errMsg{err: fmt.Errorf("failed to open stream: %w", err)} } m.debugLog("listenToStream: stream opened successfully") + m.streamCancel = cancel // Create a subscription channel that continuously reads from stream sub := make(chan tea.Msg, 10) // Buffer to avoid blocking @@ -1461,6 +1742,20 @@ func formatMarkdown(text string) string { return result } +// loadThreads loads available threads for the current user +func (m *Model) loadThreads() tea.Cmd { + return func() tea.Msg { + m.debugLog("loadThreads: loading threads") + resp, err := m.client.ListThreads(m.ctx, 50, "") + if err != nil { + m.debugLog("loadThreads: failed to load threads: %v", err) + return errMsg{err: fmt.Errorf("failed to load threads: %w", err)} + } + m.debugLog("loadThreads: loaded %d threads", len(resp.Results)) + return threadsLoadedMsg{threads: resp.Results} + } +} + // loadMessages loads all messages from the current thread func (m *Model) loadMessages() tea.Cmd { return func() tea.Msg { @@ -1488,21 +1783,20 @@ 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 + editable := false + forkable := false switch msg.Role { case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_USER: role = "User" - selectable = true + editable = true + // Forkable if not the first message + forkable = msg.Index > 0 case threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT: role = "Assistant" - selectable = false + editable = false + forkable = false } // Get message content preview @@ -1535,23 +1829,22 @@ func (m *Model) updateMessageListViewport() { contentPreview = "[No content]" } - // Format the line - prefix := " " - if !selectable { - prefix = " " // Non-selectable messages get same prefix but different style + // Add indicator for forkable messages + indicator := "" + if forkable { + indicator = " [forkable]" } - line := fmt.Sprintf("%d. [%s] %s", msg.Index, role, contentPreview) + line := fmt.Sprintf("%d. [%s] %s%s", msg.Index, role, contentPreview, indicator) - // Apply style based on selection and selectability - if i == m.selectedMessageIdx && selectable { + if i == m.selectedMessageIdx { 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(prefix + line) + style := messageListItemStyle + if !editable { + style = messageListItemStyle.Foreground(lipgloss.Color("240")) + } + line = style.Render(" " + line) } b.WriteString(line) @@ -1561,6 +1854,58 @@ func (m *Model) updateMessageListViewport() { m.messageListViewport.SetContent(b.String()) } +// updateThreadListViewport updates the thread selector viewport content +func (m *Model) updateThreadListViewport() { + if len(m.threads) == 0 { + m.threadListViewport.SetContent("No threads found.") + return + } + + if m.selectedThreadIdx >= len(m.threads) { + m.selectedThreadIdx = len(m.threads) - 1 + } + if m.selectedThreadIdx < 0 { + m.selectedThreadIdx = 0 + } + + currentID := m.threadID + var b strings.Builder + for i, thread := range m.threads { + name := thread.DisplayName + if name == "" { + name = "Untitled thread" + } + + threadID := client.ExtractThreadIDFromPath(thread.Path) + updated := "" + if thread.UpdateTime != nil { + updated = thread.UpdateTime.AsTime().Local().Format("2006-01-02 15:04") + } + + line := fmt.Sprintf("%d. %s", i+1, name) + if threadID != "" { + line = fmt.Sprintf("%s — %s", line, threadID) + } + if updated != "" { + line = fmt.Sprintf("%s (updated %s)", line, updated) + } + if threadID == currentID && currentID != "" { + line = fmt.Sprintf("%s [current]", line) + } + + if i == m.selectedThreadIdx { + line = selectedItemStyle.Render("> " + line) + } else { + line = messageListItemStyle.Render(" " + line) + } + + b.WriteString(line) + b.WriteString("\n") + } + + m.threadListViewport.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 { @@ -1589,6 +1934,24 @@ func (m *Model) isSelectableMessage(msg *threadv1alpha1.LlmMessage) bool { return true } +// forkThreadFromMessage creates a forked thread at the given message path +func (m *Model) forkThreadFromMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { + if msg == nil { + return nil + } + messagePath := msg.Path + return func() tea.Msg { + m.debugLog("forkThreadFromMessage: messagePath=%s", messagePath) + thread, err := m.client.ForkThread(m.ctx, messagePath, nil) + if err != nil { + m.debugLog("forkThreadFromMessage: failed to fork thread: %v", err) + return errMsg{err: fmt.Errorf("failed to fork thread: %w", err)} + } + m.debugLog("forkThreadFromMessage: forked thread path=%s", thread.Path) + return threadForkedMsg{thread: thread} + } +} + // startEditingMessage starts editing a message func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { m.debugLog("startEditingMessage: editing message %s", msg.Path) @@ -1623,6 +1986,7 @@ func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { m.editingMessagePath = msg.Path m.editingMessageOrig = textContent m.editingMessage = msg // Store full message for checkpoint check + m.editingCreateFork = false m.textarea.SetValue(textContent) m.textarea.Focus() m.viewMode = ViewModeEditing @@ -1630,8 +1994,79 @@ func (m *Model) startEditingMessage(msg *threadv1alpha1.LlmMessage) tea.Cmd { return nil } +// resetThreadState clears chat state in preparation for switching threads +func (m *Model) resetThreadState(threadID, displayName string) { + m.threadID = threadID + m.threadDisplayName = displayName + m.messages = []string{} + m.displayedContentIDs = make(map[string]bool) + m.executingToolsMap = make(map[string]int) + m.currentResponse = &strings.Builder{} + m.waiting = false + m.streamingResponse = false + m.streamSub = nil + m.retryCount = 0 + m.llmMessages = nil + m.selectedMessageIdx = 0 + m.editingMessagePath = "" + m.editingMessageOrig = "" + m.editingMessage = nil + m.editingCreateFork = false + m.restoreConfirmMessagePath = "" + m.restoreConfirmContent = "" + m.restoreConfirmOrigContent = "" + m.restoreConfirmIsFork = false + m.awaitingClarification = false + m.clarifyQuestion = "" + m.clarifyToolCallID = "" + m.clarifyToolCallPath = "" + + m.textarea.Reset() + m.textarea.Focus() + m.viewMode = ViewModeChat +} + +// activateThread resets state and opens the stream for the provided thread +func (m *Model) activateThread(thread *threadv1alpha1.Thread, status string) tea.Cmd { + if thread == nil { + return nil + } + + threadID := client.ExtractThreadIDFromPath(thread.Path) + if threadID == "" { + return func() tea.Msg { + return errMsg{err: fmt.Errorf("invalid thread path returned by server")} + } + } + + if m.streamCancel != nil { + m.debugLog("activateThread: cancelling current stream before switching") + m.suppressNextStreamError = true + m.streamCancel() + m.streamCancel = nil + } + + m.resetThreadState(threadID, thread.DisplayName) + + display := thread.DisplayName + if display == "" { + display = threadID + } + + message := fmt.Sprintf("Switched to thread: %s", display) + if status != "" { + message = fmt.Sprintf("%s %s", status, message) + } + m.addMessage(message, "system") + m.updateViewport() + + threadPath := thread.Path + cmds := []tea.Cmd{m.configureWorkingDirectory(threadPath), m.listenToStream(threadPath)} + return tea.Batch(cmds...) +} + // saveEditedMessageAsync saves the edited message asynchronously -func (m *Model) saveEditedMessageAsync(messagePath, origContent, newContent string, restore *bool) tea.Cmd { +func (m *Model) saveEditedMessageAsync(messagePath, origContent, newContent string, restore *bool, createFork bool) tea.Cmd { if newContent == "" { m.debugLog("saveEditedMessageAsync: empty content, not saving") return func() tea.Msg { @@ -1639,19 +2074,25 @@ func (m *Model) saveEditedMessageAsync(messagePath, origContent, newContent stri } } - if newContent == origContent { + if !createFork && newContent == origContent { m.debugLog("saveEditedMessageAsync: no changes made") // No changes, just show a message return func() tea.Msg { - return messageEditedMsg{filesRestored: 0, filesDeleted: 0} + return messageEditedMsg{filesRestored: 0, filesDeleted: 0, forkedThread: nil} } } return func() tea.Msg { - m.debugLog("saveEditedMessageAsync: saving edited message to %s, restore=%v", messagePath, restore) + m.debugLog("saveEditedMessageAsync: saving edited message to %s, restore=%v, createFork=%v", messagePath, restore, createFork) + + var createForkPtr *bool + if createFork { + createForkPtr = new(bool) + *createForkPtr = true + } // Edit the message - resp, err := m.client.EditThreadMessage(m.ctx, messagePath, newContent, restore) + resp, err := m.client.EditThreadMessage(m.ctx, messagePath, newContent, restore, createForkPtr) if err != nil { m.debugLog("saveEditedMessageAsync: failed to edit message: %v", err) return errMsg{err: fmt.Errorf("failed to edit message: %w", err)} @@ -1736,7 +2177,7 @@ func (m *Model) saveEditedMessageAsync(messagePath, origContent, newContent stri } } - return messageEditedMsg{filesRestored: filesRestored, filesDeleted: filesDeleted} + return messageEditedMsg{filesRestored: filesRestored, filesDeleted: filesDeleted, forkedThread: resp.ForkedThread} } } diff --git a/tim-db/gen/db/models.go b/tim-db/gen/db/models.go index bad762785..c1858bfcd 100644 --- a/tim-db/gen/db/models.go +++ b/tim-db/gen/db/models.go @@ -679,6 +679,8 @@ type Thread struct { CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz WorkingDirectory pgtype.Text + // The message at which this thread was forked from its parent. NULL if not a forked thread or forked by timestamp. + ForkMessageUid *uuid.UUID } type ThreadCheckpoint struct { diff --git a/tim-db/gen/db/thread.sql.go b/tim-db/gen/db/thread.sql.go index 034082ef9..f9b6967b1 100644 --- a/tim-db/gen/db/thread.sql.go +++ b/tim-db/gen/db/thread.sql.go @@ -27,6 +27,30 @@ func (q *Queries) AddMessageToThread(ctx context.Context, arg AddMessageToThread return err } +const copyThreadMessagesUpToAndIncludingIndex = `-- name: CopyThreadMessagesUpToAndIncludingIndex :exec +INSERT INTO thread_message (thread_uid, message_uid) +SELECT $1::uuid, tm.message_uid +FROM thread_message tm +INNER JOIN llm_message m ON tm.message_uid = m.uid +WHERE tm.thread_uid = $2::uuid + AND m.idx <= $3::int + AND m.deleted_at IS NULL +` + +type CopyThreadMessagesUpToAndIncludingIndexParams struct { + NewThreadUid uuid.UUID + SourceThreadUid uuid.UUID + MaxIdx int32 +} + +// Copy messages up to AND including the specified index +// For ForkThread: pass the fork message index directly +// For EditThreadMessage with fork: pass (edited_message_idx - 1) to exclude edited message +func (q *Queries) CopyThreadMessagesUpToAndIncludingIndex(ctx context.Context, arg CopyThreadMessagesUpToAndIncludingIndexParams) error { + _, err := q.db.Exec(ctx, copyThreadMessagesUpToAndIncludingIndex, arg.NewThreadUid, arg.SourceThreadUid, arg.MaxIdx) + return err +} + const countThreadMessages = `-- name: CountThreadMessages :one SELECT COUNT(*) FROM thread_message @@ -200,6 +224,7 @@ func (q *Queries) CreateMessageContent(ctx context.Context, arg CreateMessageCon const createThread = `-- name: CreateThread :one INSERT INTO thread ( parent_uid, + fork_message_uid, owner_uid, organization_uid, persona_revision_uid, @@ -211,13 +236,15 @@ INSERT INTO thread ( $3::uuid, $4::uuid, $5::uuid, - $6 + $6::uuid, + $7 ) -RETURNING uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time, working_directory +RETURNING uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time, working_directory, fork_message_uid ` type CreateThreadParams struct { ParentUID *uuid.UUID + ForkMessageUid *uuid.UUID OwnerUID uuid.UUID OrganizationUID uuid.UUID PersonaRevisionUID uuid.UUID @@ -228,6 +255,7 @@ type CreateThreadParams struct { func (q *Queries) CreateThread(ctx context.Context, arg CreateThreadParams) (Thread, error) { row := q.db.QueryRow(ctx, createThread, arg.ParentUID, + arg.ForkMessageUid, arg.OwnerUID, arg.OrganizationUID, arg.PersonaRevisionUID, @@ -247,6 +275,7 @@ func (q *Queries) CreateThread(ctx context.Context, arg CreateThreadParams) (Thr &i.CreateTime, &i.UpdateTime, &i.WorkingDirectory, + &i.ForkMessageUid, ) return i, err } @@ -339,10 +368,12 @@ func (q *Queries) GetMessageByUID(ctx context.Context, messageUid uuid.UUID) (Ll 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, t.working_directory, - pr.persona_uid as persona_uid + 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, t.fork_message_uid, + pr.persona_uid as persona_uid, + fork_msg.create_time as fork_message_create_time FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid +LEFT JOIN llm_message fork_msg ON t.fork_message_uid = fork_msg.uid WHERE t.uid = $1::uuid AND t.organization_uid = $2::uuid AND t.owner_uid = $3::uuid @@ -355,18 +386,20 @@ type GetThreadParams struct { } type GetThreadRow struct { - UID uuid.UUID - ParentUID *uuid.UUID - OwnerUID uuid.UUID - OrganizationUID uuid.UUID - PersonaRevisionUID uuid.UUID - ActiveContextUID *uuid.UUID - Title string - LlmStatus ThreadLlmStatus - CreateTime pgtype.Timestamptz - UpdateTime pgtype.Timestamptz - WorkingDirectory pgtype.Text - PersonaUID uuid.UUID + UID uuid.UUID + ParentUID *uuid.UUID + OwnerUID uuid.UUID + OrganizationUID uuid.UUID + PersonaRevisionUID uuid.UUID + ActiveContextUID *uuid.UUID + Title string + LlmStatus ThreadLlmStatus + CreateTime pgtype.Timestamptz + UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text + ForkMessageUid *uuid.UUID + PersonaUID uuid.UUID + ForkMessageCreateTime pgtype.Timestamptz } func (q *Queries) GetThread(ctx context.Context, arg GetThreadParams) (GetThreadRow, error) { @@ -384,13 +417,15 @@ func (q *Queries) GetThread(ctx context.Context, arg GetThreadParams) (GetThread &i.CreateTime, &i.UpdateTime, &i.WorkingDirectory, + &i.ForkMessageUid, &i.PersonaUID, + &i.ForkMessageCreateTime, ) 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, working_directory +SELECT uid, parent_uid, owner_uid, organization_uid, persona_revision_uid, active_context_uid, title, llm_status, create_time, update_time, working_directory, fork_message_uid FROM thread WHERE uid = $1::uuid ` @@ -410,6 +445,7 @@ func (q *Queries) GetThreadByUID(ctx context.Context, threadUid uuid.UUID) (Thre &i.CreateTime, &i.UpdateTime, &i.WorkingDirectory, + &i.ForkMessageUid, ) return i, err } @@ -455,7 +491,7 @@ func (q *Queries) GetThreadMessage(ctx context.Context, arg GetThreadMessagePara 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.working_directory, + 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, t.fork_message_uid, pr.model_id as persona_model_id, pr.tools as persona_tools, pr.tool_choice as persona_tool_choice, @@ -489,6 +525,7 @@ type GetThreadWithPersonaRevisionRow struct { CreateTime pgtype.Timestamptz UpdateTime pgtype.Timestamptz WorkingDirectory pgtype.Text + ForkMessageUid *uuid.UUID PersonaModelID string PersonaTools []string PersonaToolChoice NullToolChoice @@ -514,6 +551,7 @@ func (q *Queries) GetThreadWithPersonaRevision(ctx context.Context, arg GetThrea &i.CreateTime, &i.UpdateTime, &i.WorkingDirectory, + &i.ForkMessageUid, &i.PersonaModelID, &i.PersonaTools, &i.PersonaToolChoice, @@ -632,10 +670,12 @@ 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, t.working_directory, - pr.persona_uid as persona_uid + 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, t.fork_message_uid, + pr.persona_uid as persona_uid, + fork_msg.create_time as fork_message_create_time FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid +LEFT JOIN llm_message fork_msg ON t.fork_message_uid = fork_msg.uid WHERE t.owner_uid = $1::uuid AND t.organization_uid = $2::uuid -- Cursor-based pagination: continue from last seen (create_time, uid) @@ -655,18 +695,20 @@ type ListThreadsParams struct { } type ListThreadsRow struct { - UID uuid.UUID - ParentUID *uuid.UUID - OwnerUID uuid.UUID - OrganizationUID uuid.UUID - PersonaRevisionUID uuid.UUID - ActiveContextUID *uuid.UUID - Title string - LlmStatus ThreadLlmStatus - CreateTime pgtype.Timestamptz - UpdateTime pgtype.Timestamptz - WorkingDirectory pgtype.Text - PersonaUID uuid.UUID + UID uuid.UUID + ParentUID *uuid.UUID + OwnerUID uuid.UUID + OrganizationUID uuid.UUID + PersonaRevisionUID uuid.UUID + ActiveContextUID *uuid.UUID + Title string + LlmStatus ThreadLlmStatus + CreateTime pgtype.Timestamptz + UpdateTime pgtype.Timestamptz + WorkingDirectory pgtype.Text + ForkMessageUid *uuid.UUID + PersonaUID uuid.UUID + ForkMessageCreateTime pgtype.Timestamptz } func (q *Queries) ListThreads(ctx context.Context, arg ListThreadsParams) ([]ListThreadsRow, error) { @@ -696,7 +738,9 @@ func (q *Queries) ListThreads(ctx context.Context, arg ListThreadsParams) ([]Lis &i.CreateTime, &i.UpdateTime, &i.WorkingDirectory, + &i.ForkMessageUid, &i.PersonaUID, + &i.ForkMessageCreateTime, ); err != nil { return nil, err } diff --git a/tim-db/migrations/20251106212221_add_fork_message_to_thread.sql b/tim-db/migrations/20251106212221_add_fork_message_to_thread.sql new file mode 100644 index 000000000..6c8a38588 --- /dev/null +++ b/tim-db/migrations/20251106212221_add_fork_message_to_thread.sql @@ -0,0 +1,20 @@ +-- migrate:up + +-- Add fork_message_uid column to thread table to track which message was the fork point +ALTER TABLE thread ADD COLUMN fork_message_uid UUID DEFAULT NULL; + +-- Add foreign key constraint +ALTER TABLE thread ADD CONSTRAINT fk_thread_fork_message + FOREIGN KEY (fork_message_uid) REFERENCES llm_message(uid) ON DELETE SET NULL; + +-- Add comment explaining the column +COMMENT ON COLUMN thread.fork_message_uid IS 'The message at which this thread was forked from its parent. NULL if not a forked thread or forked by timestamp.'; + +-- migrate:down + +-- Remove the foreign key constraint +ALTER TABLE thread DROP CONSTRAINT IF EXISTS fk_thread_fork_message; + +-- Remove the fork_message_uid column +ALTER TABLE thread DROP COLUMN IF EXISTS fork_message_uid; + diff --git a/tim-db/queries/thread.sql b/tim-db/queries/thread.sql index 21e58aea6..da085ff42 100644 --- a/tim-db/queries/thread.sql +++ b/tim-db/queries/thread.sql @@ -1,9 +1,11 @@ -- name: GetThread :one SELECT t.*, - pr.persona_uid as persona_uid + pr.persona_uid as persona_uid, + fork_msg.create_time as fork_message_create_time FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid +LEFT JOIN llm_message fork_msg ON t.fork_message_uid = fork_msg.uid WHERE t.uid = sqlc.arg(thread_uid)::uuid AND t.organization_uid = sqlc.arg(organization_uid)::uuid AND t.owner_uid = sqlc.arg(owner_uid)::uuid; @@ -16,9 +18,11 @@ WHERE uid = sqlc.arg(thread_uid)::uuid; -- name: ListThreads :many SELECT t.*, - pr.persona_uid as persona_uid + pr.persona_uid as persona_uid, + fork_msg.create_time as fork_message_create_time FROM thread t INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid +LEFT JOIN llm_message fork_msg ON t.fork_message_uid = fork_msg.uid WHERE t.owner_uid = sqlc.arg(owner_uid)::uuid AND t.organization_uid = sqlc.arg(organization_uid)::uuid -- Cursor-based pagination: continue from last seen (create_time, uid) @@ -44,6 +48,7 @@ WHERE origin_thread_uid = sqlc.arg(thread_uid)::uuid -- name: CreateThread :one INSERT INTO thread ( parent_uid, + fork_message_uid, owner_uid, organization_uid, persona_revision_uid, @@ -51,6 +56,7 @@ INSERT INTO thread ( title ) VALUES ( sqlc.narg(parent_uid)::uuid, + sqlc.narg(fork_message_uid)::uuid, sqlc.arg(owner_uid)::uuid, sqlc.arg(organization_uid)::uuid, sqlc.arg(persona_revision_uid)::uuid, @@ -222,3 +228,15 @@ INNER JOIN persona_revision pr ON t.persona_revision_uid = pr.uid WHERE t.uid = sqlc.arg(thread_uid)::uuid AND t.organization_uid = sqlc.arg(organization_uid)::uuid AND t.owner_uid = sqlc.arg(owner_uid)::uuid; + +-- name: CopyThreadMessagesUpToAndIncludingIndex :exec +-- Copy messages up to AND including the specified index +-- For ForkThread: pass the fork message index directly +-- For EditThreadMessage with fork: pass (edited_message_idx - 1) to exclude edited message +INSERT INTO thread_message (thread_uid, message_uid) +SELECT sqlc.arg(new_thread_uid)::uuid, tm.message_uid +FROM thread_message tm +INNER JOIN llm_message m ON tm.message_uid = m.uid +WHERE tm.thread_uid = sqlc.arg(source_thread_uid)::uuid + AND m.idx <= sqlc.arg(max_idx)::int + AND m.deleted_at IS NULL; diff --git a/tim-proto/gen/openapi.yaml b/tim-proto/gen/openapi.yaml index 91849fd31..4cfa25ce6 100644 --- a/tim-proto/gen/openapi.yaml +++ b/tim-proto/gen/openapi.yaml @@ -1637,6 +1637,56 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' + /v1alpha1/orgs/{org}/users/{user}/threads/{thread}/messages/{message}:fork: + post: + tags: + - ThreadService + description: Fork a thread at a specific message (inclusive) + operationId: ThreadService_ForkThread + 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/ForkThreadRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ForkThreadResponse' + 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: @@ -2098,50 +2148,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' - /v1alpha1/orgs/{org}/users/{user}/threads/{thread}:fork: - post: - tags: - - ThreadService - description: Fork an existing thread to create a new thread - operationId: ThreadService_ForkThread - 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/ForkThreadRequest' - required: true - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/ForkThreadResponse' - default: - description: Default error response - content: - application/json: - schema: - $ref: '#/components/schemas/Status' /v1alpha1/orgs/{org}/users/{user}/threads/{thread}:stream: get: tags: @@ -2623,6 +2629,13 @@ components: 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). + createFork: + type: boolean + description: |- + If true, creates a new forked thread instead of modifying the original thread. + The forked thread will include all messages before the edited message, + and the edited content will be added as a new message in the forked thread. + The original thread remains unchanged. description: EditThreadMessageRequest is used to edit a message (e.g., edit user message content) EditThreadMessageResponse: required: @@ -2640,6 +2653,10 @@ components: description: |- Files to restore from checkpoint (if applicable) The client should restore these files before continuing + forkedThread: + allOf: + - $ref: '#/components/schemas/Thread' + description: If create_fork was true in the request, this contains the newly created forked thread description: EditThreadMessageResponse contains the edited message and any file restoration data ExchangeAccessTokenRequest: required: @@ -2709,22 +2726,19 @@ components: description: FinalizePersonaRevisionRequest is the request message for FinalizePersonaRevision ForkThreadRequest: required: - - parent - - beforeTime + - message type: object properties: - parent: - type: string - description: The resource path of the thread to fork from - beforeTime: + message: type: string description: |- - The timestamp from the parent thread to fork from, all events on the parent - thread before this time will be included in the new thread. - format: date-time - description: |- - CreateThreadRequest is used to create a new thread, which must be unique within - the parent thread if one is provided. + The resource path of the user message to fork before (non-inclusive). + The forked thread will contain all messages BEFORE this message. + Must be a user message and cannot be the first message (index 0). + title: + type: string + description: Optional title for the forked thread. If not provided, will be auto-generated. + description: ForkThreadRequest is used to fork a thread at a specific message ForkThreadResponse: required: - thread @@ -2733,7 +2747,7 @@ components: thread: allOf: - $ref: '#/components/schemas/Thread' - description: The newly created thread + description: The newly created forked thread description: ForkThreadResponse is the response for forking a thread GoogleProtobufAny: type: object @@ -3684,6 +3698,12 @@ components: belonging to this thread will include all events which occurred before this timestamp. format: date-time + forkMessageUid: + readOnly: true + type: string + description: |- + The message at which this thread was forked (optional for backward compatibility) + If set, this is the exact message that served as the fork point. description: The identifier for the original thread this thread was forked from. Todo: required: 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 81dd500c3..b51bc85e5 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 @@ -14,7 +14,6 @@ import ( 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" unsafe "unsafe" @@ -270,15 +269,15 @@ func (x *CreateThreadRequest) GetInitialMessageText() string { return "" } -// CreateThreadRequest is used to create a new thread, which must be unique within -// the parent thread if one is provided. +// ForkThreadRequest is used to fork a thread at a specific message type ForkThreadRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // The resource path of the thread to fork from - Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` - // The timestamp from the parent thread to fork from, all events on the parent - // thread before this time will be included in the new thread. - BeforeTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=before_time,json=beforeTime,proto3" json:"before_time,omitempty"` + // The resource path of the user message to fork before (non-inclusive). + // The forked thread will contain all messages BEFORE this message. + // Must be a user message and cannot be the first message (index 0). + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // Optional title for the forked thread. If not provided, will be auto-generated. + Title *string `protobuf:"bytes,2,opt,name=title,proto3,oneof" json:"title,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -313,24 +312,24 @@ func (*ForkThreadRequest) Descriptor() ([]byte, []int) { return file_tim_api_thread_v1alpha1_thread_service_proto_rawDescGZIP(), []int{4} } -func (x *ForkThreadRequest) GetParent() string { +func (x *ForkThreadRequest) GetMessage() string { if x != nil { - return x.Parent + return x.Message } return "" } -func (x *ForkThreadRequest) GetBeforeTime() *timestamppb.Timestamp { - if x != nil { - return x.BeforeTime +func (x *ForkThreadRequest) GetTitle() string { + if x != nil && x.Title != nil { + return *x.Title } - return nil + return "" } // ForkThreadResponse is the response for forking a thread type ForkThreadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // The newly created thread + // The newly created forked thread Thread *Thread `protobuf:"bytes,1,opt,name=thread,proto3" json:"thread,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -914,7 +913,12 @@ type EditThreadMessageRequest struct { // 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"` + Restore *bool `protobuf:"varint,3,opt,name=restore,proto3,oneof" json:"restore,omitempty"` + // If true, creates a new forked thread instead of modifying the original thread. + // The forked thread will include all messages before the edited message, + // and the edited content will be added as a new message in the forked thread. + // The original thread remains unchanged. + CreateFork *bool `protobuf:"varint,4,opt,name=create_fork,json=createFork,proto3,oneof" json:"create_fork,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -970,6 +974,13 @@ func (x *EditThreadMessageRequest) GetRestore() bool { return false } +func (x *EditThreadMessageRequest) GetCreateFork() bool { + if x != nil && x.CreateFork != nil { + return *x.CreateFork + } + return false +} + // EditThreadMessageResponse contains the edited message and any file restoration data type EditThreadMessageResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -978,8 +989,10 @@ type EditThreadMessageResponse struct { // 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 + // If create_fork was true in the request, this contains the newly created forked thread + ForkedThread *Thread `protobuf:"bytes,3,opt,name=forked_thread,json=forkedThread,proto3,oneof" json:"forked_thread,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EditThreadMessageResponse) Reset() { @@ -1026,6 +1039,13 @@ func (x *EditThreadMessageResponse) GetFileRestorations() []*FileRestoration { return nil } +func (x *EditThreadMessageResponse) GetForkedThread() *Thread { + if x != nil { + return x.ForkedThread + } + return nil +} + // ConfigureThreadWorkingDirectoryRequest configures the working directory for checkpoint creation type ConfigureThreadWorkingDirectoryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1085,7 +1105,7 @@ var File_tim_api_thread_v1alpha1_thread_service_proto protoreflect.FileDescripto 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\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" + + ",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*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" + @@ -1104,11 +1124,11 @@ const file_tim_api_thread_v1alpha1_thread_service_proto_rawDesc = "" + "\x06parent\x18\x01 \x01(\tBV\xe0A\x02\xbaH2r02.^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}$\u0091\x05\x1a\x12\x18tim.settlerlabs.com/userR\x06parent\x12B\n" + "\x06thread\x18\x02 \x01(\v2\x1f.tim.api.thread.v1alpha1.ThreadB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\x06thread\x12?\n" + "\x14initial_message_text\x18\x03 \x01(\tB\r\xbaH\n" + - "\xc8\x01\x01r\x05\x10\x01\x18\x80 R\x12initialMessageText\"\xe7\x01\n" + - "\x11ForkThreadRequest\x12\x89\x01\n" + - "\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\x12F\n" + - "\vbefore_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\n" + - "beforeTime\"X\n" + + "\xc8\x01\x01r\x05\x10\x01\x18\x80 R\x12initialMessageText\"\xf0\x01\n" + + "\x11ForkThreadRequest\x12\xab\x01\n" + + "\amessage\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\amessage\x12#\n" + + "\x05title\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01H\x00R\x05title\x88\x01\x01B\b\n" + + "\x06_title\"X\n" + "\x12ForkThreadResponse\x12B\n" + "\x06thread\x18\x01 \x01(\v2\x1f.tim.api.thread.v1alpha1.ThreadB\t\xe0A\x02\xbaH\x03\xc8\x01\x01R\x06thread\"\xe1\x01\n" + "\x13UpdateThreadRequest\x12\x85\x01\n" + @@ -1142,26 +1162,31 @@ 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\"\x97\x02\n" + + "\x04text\x18\x01 \x01(\tB\x0e\xbaH\v\xc8\x01\x01r\x06\x10\x01\x18\x80\x80\x02R\x04text\"\xd2\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\x1d\n" + - "\arestore\x18\x03 \x01(\bH\x00R\arestore\x88\x01\x01B\n" + + "\arestore\x18\x03 \x01(\bH\x00R\arestore\x88\x01\x01\x12)\n" + + "\vcreate_fork\x18\x04 \x01(\bB\x03\xe0A\x01H\x01R\n" + + "createFork\x88\x01\x01B\n" + "\n" + - "\b_restore\"\xbc\x01\n" + + "\b_restoreB\x0e\n" + + "\f_create_fork\"\x99\x02\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" + + "\x11file_restorations\x18\x02 \x03(\v2(.tim.api.thread.v1alpha1.FileRestorationR\x10fileRestorations\x12I\n" + + "\rforked_thread\x18\x03 \x01(\v2\x1f.tim.api.thread.v1alpha1.ThreadH\x00R\fforkedThread\x88\x01\x01B\x10\n" + + "\x0e_forked_thread\"\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\xa8\x10\n" + + "\xbaH\a\xc8\x01\x01r\x02\x10\x01R\x10workingDirectory2\xae\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" + - "\fCreateThread\x12,.tim.api.thread.v1alpha1.CreateThreadRequest\x1a\x1f.tim.api.thread.v1alpha1.Thread\"Y\xdaA\"parent,thread,initial_message_text\x82\xd3\xe4\x93\x02.:\x01*\")/v1alpha1/{parent=orgs/*/users/*}/threads\x12\xb2\x01\n" + + "\fCreateThread\x12,.tim.api.thread.v1alpha1.CreateThreadRequest\x1a\x1f.tim.api.thread.v1alpha1.Thread\"Y\xdaA\"parent,thread,initial_message_text\x82\xd3\xe4\x93\x02.:\x01*\")/v1alpha1/{parent=orgs/*/users/*}/threads\x12\xb8\x01\n" + "\n" + - "ForkThread\x12*.tim.api.thread.v1alpha1.ForkThreadRequest\x1a+.tim.api.thread.v1alpha1.ForkThreadResponse\"K\xdaA\rparent,thread\x82\xd3\xe4\x93\x025:\x01*\"0/v1alpha1/{parent=orgs/*/users/*/threads/*}:fork\x12\xa1\x01\n" + + "ForkThread\x12*.tim.api.thread.v1alpha1.ForkThreadRequest\x1a+.tim.api.thread.v1alpha1.ForkThreadResponse\"Q\xdaA\amessage\x82\xd3\xe4\x93\x02A:\x01*\" 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 - 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 + 17, // 2: tim.api.thread.v1alpha1.ForkThreadResponse.thread:type_name -> tim.api.thread.v1alpha1.Thread + 17, // 3: tim.api.thread.v1alpha1.UpdateThreadRequest.thread:type_name -> tim.api.thread.v1alpha1.Thread + 18, // 4: tim.api.thread.v1alpha1.ListLlmMessagesResponse.results:type_name -> tim.api.thread.v1alpha1.LlmMessage + 19, // 5: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_start:type_name -> tim.api.thread.v1alpha1.ContentStartEvent + 20, // 6: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_delta:type_name -> tim.api.thread.v1alpha1.ContentDeltaEvent + 21, // 7: tim.api.thread.v1alpha1.StreamThreadEventsResponse.content_stop:type_name -> tim.api.thread.v1alpha1.ContentStopEvent + 22, // 8: tim.api.thread.v1alpha1.StreamThreadEventsResponse.tool_call:type_name -> tim.api.tool.v1alpha1.ToolCall + 23, // 9: tim.api.thread.v1alpha1.StreamThreadEventsResponse.thread_state_change:type_name -> tim.api.thread.v1alpha1.ThreadStateChangeEvent + 24, // 10: tim.api.thread.v1alpha1.StreamThreadEventsResponse.stream_error:type_name -> tim.api.thread.v1alpha1.StreamErrorEvent + 13, // 11: tim.api.thread.v1alpha1.SubmitUserMessageRequest.user_message:type_name -> tim.api.thread.v1alpha1.UserMessage + 18, // 12: tim.api.thread.v1alpha1.EditThreadMessageResponse.message:type_name -> tim.api.thread.v1alpha1.LlmMessage + 25, // 13: tim.api.thread.v1alpha1.EditThreadMessageResponse.file_restorations:type_name -> tim.api.thread.v1alpha1.FileRestoration + 17, // 14: tim.api.thread.v1alpha1.EditThreadMessageResponse.forked_thread:type_name -> tim.api.thread.v1alpha1.Thread 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 @@ -1246,12 +1270,12 @@ var file_tim_api_thread_v1alpha1_thread_service_proto_depIdxs = []int32{ 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 + 18, // 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 + 18, // 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, // 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 @@ -1265,6 +1289,7 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_init() { return } file_tim_api_thread_v1alpha1_thread_types_proto_init() + file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[4].OneofWrappers = []any{} file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[11].OneofWrappers = []any{ (*StreamThreadEventsResponse_ContentStart)(nil), (*StreamThreadEventsResponse_ContentDelta)(nil), @@ -1274,6 +1299,7 @@ func file_tim_api_thread_v1alpha1_thread_service_proto_init() { (*StreamThreadEventsResponse_StreamError)(nil), } file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[14].OneofWrappers = []any{} + file_tim_api_thread_v1alpha1_thread_service_proto_msgTypes[15].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ 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 071b0b6d0..056a964d2 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 @@ -16,6 +16,47 @@ "application/json" ], "paths": { + "/v1alpha1/{message}:fork": { + "post": { + "summary": "Fork a thread at a specific message (inclusive)", + "operationId": "ThreadService_ForkThread", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1ForkThreadResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "message", + "description": "The resource path of the user message to fork before (non-inclusive).\nThe forked thread will contain all messages BEFORE this message.\nMust be a user message and cannot be the first message (index 0).", + "in": "path", + "required": true, + "type": "string", + "pattern": "orgs/[^/]+/users/[^/]+/threads/[^/]+/messages/[^/]+" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ThreadServiceForkThreadBody" + } + } + ], + "tags": [ + "ThreadService" + ] + } + }, "/v1alpha1/{parent}/messages": { "get": { "summary": "List LLM messages on a thread", @@ -193,47 +234,6 @@ ] } }, - "/v1alpha1/{parent}:fork": { - "post": { - "summary": "Fork an existing thread to create a new thread", - "operationId": "ThreadService_ForkThread", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1alpha1ForkThreadResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "parent", - "description": "The resource path of the thread to fork from", - "in": "path", - "required": true, - "type": "string", - "pattern": "orgs/[^/]+/users/[^/]+/threads/[^/]+" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/ThreadServiceForkThreadBody" - } - } - ], - "tags": [ - "ThreadService" - ] - } - }, "/v1alpha1/{parent}:stream": { "get": { "summary": "Stream thread events (content deltas, tool calls, state changes)", @@ -478,6 +478,11 @@ "type": "string", "format": "date-time", "description": "The time at which the parent thread was forked, the thread events\nbelonging to this thread will include all events which occurred before\nthis timestamp." + }, + "forkMessageUid": { + "type": "string", + "description": "The message at which this thread was forked (optional for backward compatibility)\nIf set, this is the exact message that served as the fork point.", + "readOnly": true } }, "description": "The identifier for the original thread this thread was forked from." @@ -519,6 +524,10 @@ "restore": { "type": "boolean", "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)." + }, + "createFork": { + "type": "boolean", + "description": "If true, creates a new forked thread instead of modifying the original thread.\nThe forked thread will include all messages before the edited message,\nand the edited content will be added as a new message in the forked thread.\nThe original thread remains unchanged." } }, "title": "EditThreadMessageRequest is used to edit a message (e.g., edit user message content)" @@ -526,16 +535,12 @@ "ThreadServiceForkThreadBody": { "type": "object", "properties": { - "beforeTime": { + "title": { "type": "string", - "format": "date-time", - "description": "The timestamp from the parent thread to fork from, all events on the parent\nthread before this time will be included in the new thread." + "description": "Optional title for the forked thread. If not provided, will be auto-generated." } }, - "description": "CreateThreadRequest is used to create a new thread, which must be unique within\nthe parent thread if one is provided.", - "required": [ - "beforeTime" - ] + "title": "ForkThreadRequest is used to fork a thread at a specific message" }, "apitoolv1alpha1ToolCall": { "type": "object", @@ -664,6 +669,10 @@ "$ref": "#/definitions/v1alpha1FileRestoration" }, "title": "Files to restore from checkpoint (if applicable)\nThe client should restore these files before continuing" + }, + "forkedThread": { + "$ref": "#/definitions/v1alpha1Thread", + "title": "If create_fork was true in the request, this contains the newly created forked thread" } }, "title": "EditThreadMessageResponse contains the edited message and any file restoration data", @@ -695,7 +704,7 @@ "properties": { "thread": { "$ref": "#/definitions/v1alpha1Thread", - "title": "The newly created thread" + "title": "The newly created forked thread" } }, "title": "ForkThreadResponse is the response for forking 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 581093c15..3f9055d31 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 @@ -961,9 +961,12 @@ type Thread_ParentThreadId struct { // The time at which the parent thread was forked, the thread events // belonging to this thread will include all events which occurred before // this timestamp. - BeforeTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=before_time,json=beforeTime,proto3" json:"before_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + BeforeTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=before_time,json=beforeTime,proto3" json:"before_time,omitempty"` + // The message at which this thread was forked (optional for backward compatibility) + // If set, this is the exact message that served as the fork point. + ForkMessageUid string `protobuf:"bytes,3,opt,name=fork_message_uid,json=forkMessageUid,proto3" json:"fork_message_uid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Thread_ParentThreadId) Reset() { @@ -1010,11 +1013,18 @@ func (x *Thread_ParentThreadId) GetBeforeTime() *timestamppb.Timestamp { return nil } +func (x *Thread_ParentThreadId) GetForkMessageUid() string { + if x != nil { + return x.ForkMessageUid + } + return "" +} + var File_tim_api_thread_v1alpha1_thread_types_proto protoreflect.FileDescriptor const file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc = "" + "\n" + - "*tim/api/thread/v1alpha1/thread_types.proto\x12\x17tim.api.thread.v1alpha1\x1a\x18aep/api/field_info.proto\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/api/field_info.proto\x1a\x19google/api/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a6tim/api/llm_response/v1alpha1/llm_response_types.proto\x1a&tim/api/tool/v1alpha1/tool_types.proto\"\x83\a\n" + + "*tim/api/thread/v1alpha1/thread_types.proto\x12\x17tim.api.thread.v1alpha1\x1a\x18aep/api/field_info.proto\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/api/field_info.proto\x1a\x19google/api/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a6tim/api/llm_response/v1alpha1/llm_response_types.proto\x1a&tim/api/tool/v1alpha1/tool_types.proto\"\xba\a\n" + "\x06Thread\x12h\n" + "\x04path\x18\x01 \x01(\tBT\xe0A\x03\xbaHN\xd8\x01\x01rI2G^orgs/[a-fA-F0-9-]{36}/users/[a-fA-F0-9-]{36}/threads/[a-fA-F0-9-]{36}$R\x04path\x12+\n" + "\fdisplay_name\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\vdisplayName\x12]\n" + @@ -1026,11 +1036,12 @@ const file_tim_api_thread_v1alpha1_thread_types_proto_rawDesc = "" + "createTime\x12@\n" + "\vupdate_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + "updateTime\x12I\n" + - "\tllm_state\x18\b \x01(\x0e2'.tim.api.thread.v1alpha1.ThreadLLMStateB\x03\xe0A\x03R\bllmState\x1a\xe0\x01\n" + + "\tllm_state\x18\b \x01(\x0e2'.tim.api.thread.v1alpha1.ThreadLLMStateB\x03\xe0A\x03R\bllmState\x1a\x97\x02\n" + "\x0eParentThreadId\x12\x85\x01\n" + "\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" + + "beforeTime\x125\n" + + "\x10fork_message_uid\x18\x03 \x01(\tB\v\xe0A\x03\xe2\x8c\xcf\xd7\b\x02\b\x01R\x0eforkMessageUid:Z\xeaAW\n" + "\x1atim.settlerlabs.com/thread\x12(orgs/{org}/users/{user}/threads/{thread}*\athreads2\x06thread\"\xad\v\n" + "\n" + "LlmMessage\x12\x82\x01\n" + 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 5ef4952f6..fcda712e8 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 @@ -78,7 +78,7 @@ type ThreadServiceClient interface { // buf:lint:ignore AEP_0133_HTTP_BODY // buf:lint:ignore AEP_0133_METHOD_SIGNATURE CreateThread(context.Context, *connect.Request[v1alpha1.CreateThreadRequest]) (*connect.Response[v1alpha1.Thread], error) - // Fork an existing thread to create a new thread + // Fork a thread at a specific message (inclusive) ForkThread(context.Context, *connect.Request[v1alpha1.ForkThreadRequest]) (*connect.Response[v1alpha1.ForkThreadResponse], error) // Update mutable fields on a thread UpdateThread(context.Context, *connect.Request[v1alpha1.UpdateThreadRequest]) (*connect.Response[v1alpha1.Thread], error) @@ -257,7 +257,7 @@ type ThreadServiceHandler interface { // buf:lint:ignore AEP_0133_HTTP_BODY // buf:lint:ignore AEP_0133_METHOD_SIGNATURE CreateThread(context.Context, *connect.Request[v1alpha1.CreateThreadRequest]) (*connect.Response[v1alpha1.Thread], error) - // Fork an existing thread to create a new thread + // Fork a thread at a specific message (inclusive) ForkThread(context.Context, *connect.Request[v1alpha1.ForkThreadRequest]) (*connect.Response[v1alpha1.ForkThreadResponse], error) // Update mutable fields on a thread UpdateThread(context.Context, *connect.Request[v1alpha1.UpdateThreadRequest]) (*connect.Response[v1alpha1.Thread], error)