diff --git a/docs/backend/llm-unifier-helper-3.md b/docs/backend/llm-unifier-helper-3.md new file mode 100644 index 00000000..9785039a --- /dev/null +++ b/docs/backend/llm-unifier-helper-3.md @@ -0,0 +1,2803 @@ +# Message Types + +I want to unify the emitted chat types into the following basic ones. In addition, when getting the chat history, it should be transformed to a uniform format. Below I have attached the provider specific formats with some sample JSON messages that will help you as reference. I have also attached some typescript types from the sdk's that you can import that should be pretty useful. + +- thinking message +- normal assistant message +- error assistant message +- tool call sucess, tool call error +- tool use request +- todo/task list related +- session_started +- session_completed +- session_interrupted + +Types that I want to unify are: +## **User** messages +- text content, and additional image uploads for all providers. +### Claude +```json +{ + "parentUuid": "21e1f09a-5c84-4746-8022-5fabb2ce8b18", + "isSidechain": false, + "promptId": "8fb10a1d-9074-4118-8cec-4873d8fdb388", + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "@server..." + } + ] + }, + "uuid": "9e722e78-c330-42ac-99ee-4da0818dd446", + "timestamp": "2026-03-18T13:58:47.774Z", + "permissionMode": "default", + "userType": "external", + "cwd": "c:\\Users\\OMEN6\\Desktop\\Projects\\Paid\\ClaudeCodeUI - Siteboon\\claudecodeui", + "sessionId": "056e702c-6b8e-4727-b22b-dcea56a7ea9b", + "version": "2.1.77", + "gitBranch": "refactor/backend-rebased" +} + +``` + +### Codex +- Get all messages with `type`: `event_msg` and `payload.type`: `user_message`. +```json + { + timestamp: "2026-04-03T16:14:40.941Z", + type: "event_msg", + payload: { + type: "user_message", + message: + "Hey there, think long and hard about integral calculus. Then add it to a file called codex/math.txt in current directory.", + images: [], + local_images: [], + text_elements: [], + }, + }, +``` + +### Gemini +```json + { + "id": "1fc0bc7e-faa7-4e70-8d24-42555feb2b35", + "timestamp": "2026-04-01T11:11:56.830Z", + "type": "user", + "content": [ + { + "text": "create 2 hello world files" + } + ] + }, +``` + +### Cursor +- strip the `` tags off. +```json + { + role: "user", + message: { + content: [ + { + type: "text", + text: "\ncreate 2 hello world files/plan create 2 hello world files\n", + }, + ], + }, + }, +``` + +## Response Types + +Useful TS Types +```ts +// --------- CLAUDE ----------- +import type { + SDKMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + SDKTaskStartedMessage, + SDKTaskProgressMessage, + SDKTaskNotificationMessage, + SDKToolProgressMessage, + SessionStartHookInput, + SessionEndHookInput, + StopHookInput, + PreToolUseHookInput, + PostToolUseHookInput, + PostToolUseFailureHookInput, + TodoWriteInput, + TodoWriteOutput, +} from "@anthropic-ai/claude-agent-sdk"; + +``` + + +```ts +// --------- codex ------------ +import type { + ThreadEvent, + ThreadItem, + ThreadStartedEvent, + TurnStartedEvent, + TurnCompletedEvent, + TurnFailedEvent, + ItemStartedEvent, + ItemUpdatedEvent, + ItemCompletedEvent, + AgentMessageItem, + ReasoningItem, + CommandExecutionItem, + FileChangeItem, + McpToolCallItem, + TodoListItem, + ErrorItem, +} from "@openai/codex-sdk"; + + +/** Emitted when a new item is added to the thread. Typically the item is initially "in progress". */ +type ItemStartedEvent = { + type: "item.started"; + item: ThreadItem; +}; +/** Emitted when an item is updated. */ +type ItemUpdatedEvent = { + type: "item.updated"; + item: ThreadItem; +}; +/** Signals that an item has reached a terminal state—either success or failure. */ +type ItemCompletedEvent = { + type: "item.completed"; + item: ThreadItem; +}; +/** Fatal error emitted by the stream. */ +type ThreadError = { + message: string; +}; +/** Represents an unrecoverable error emitted directly by the event stream. */ +type ThreadErrorEvent = { + type: "error"; + message: string; +}; +/** Top-level JSONL events emitted by codex exec. */ +type ThreadEvent = ThreadStartedEvent | TurnStartedEvent | TurnCompletedEvent | TurnFailedEvent | ItemStartedEvent | ItemUpdatedEvent | ItemCompletedEvent | ThreadErrorEvent; + +/** Canonical union of thread items and their type-specific payloads. */ +type ThreadItem = AgentMessageItem | ReasoningItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | WebSearchItem | TodoListItem | ErrorItem; + +``` + + + +### Thinking +#### Claude +```ts + +export interface ThinkingBlockParam { + signature: string; + thinking: string; + type: 'thinking'; +} + +/** + * Regular text content. + */ +export type ContentBlockParam = + | TextBlockParam + | ImageBlockParam + | DocumentBlockParam + | SearchResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + | ServerToolUseBlockParam + | WebSearchToolResultBlockParam; + +export interface MessageParam { + content: string | Array; + role: 'user' | 'assistant'; +} +``` + +A sample response from one of the jsonl is below +```json +{ + "parentUuid": "2c38ec19-e2e5-4d6a-9641-70d1e69d0cf5", + "isSidechain": false, + "message": { + "model": "claude-haiku-4-5-20251001", + "id": "msg_01GcAAXYVRyJkkZEEEubB67j", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "", + "signature": "..." + } + ], + "stop_reason": null, + "stop_sequence": null, + "stop_details": null, + "usage": { + "input_tokens": 10, + "cache_creation_input_tokens": 1881, + "cache_read_input_tokens": 26212, + "cache_creation": { + "ephemeral_5m_input_tokens": 1881, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 25, + "service_tier": "standard", + "inference_geo": "not_available" + } + }, + "requestId": "req_011CZdaBwmcpjSWD2dEouJJV", + "type": "assistant", + "uuid": "e0372f52-7ced-45ce-a03f-aa608bd69d64", + "timestamp": "2026-04-01T19:05:18.705Z", + "userType": "external", + "entrypoint": "cli", + "cwd": "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\gemini-test-10", + "sessionId": "7de34ac6-1ad2-49f0-a003-9616ddec4727", + "version": "2.1.87", + "gitBranch": "HEAD" + } +``` +- If there is a "thinking" text, send it or else show "Thinking" as a fallback. + +#### Codex + +If there is no summary, it should just "Reasoning". +```json + { + timestamp: "2026-04-03T16:31:16.731Z", + type: "response_item", + payload: { + type: "reasoning", + summary: [], + content: null, + encrypted_content: + "gAAAA...", + }, + }, +``` + +#### Gemini +No types available. But you can find the thinking as follows: +- iterate through `"geminiJSONParsedFile.messages`. Find all the ones that have a `thoughts` array +```json +{ + "sessionId": "5cdd2021-574a-483d-8a4b-1b97e8bbce88", + "projectHash": "91f1ad40d4b386f69129089777263086fd8522ed56c883fb66c5a6b0fdb534a9", + "startTime": "2026-04-01T11:11:56.830Z", + "lastUpdated": "2026-04-01T11:16:13.947Z", + "messages": [ + { + "id": "1fc0bc7e-faa7-4e70-8d24-42555feb2b35", + "timestamp": "2026-04-01T11:11:56.830Z", + "type": "user", + "content": [ + { + "text": "create 2 hello world files" + } + ] + }, + { + "id": "6cbf25ab-71ab-431f-ad9c-4bb65e0717b2", + "timestamp": "2026-04-01T11:12:19.195Z", + "type": "gemini", + "content": "I will create two \"Hello World\" files, one in text format and one in Python.", + "thoughts": [ + { + "subject": "Exploring the Setup", + "description": "I'm c...", + "timestamp": "2026-04-01T11:12:17.980Z" + } + ], + "tokens": { + "input": 6907, + "output": 82, + "cached": 4062, + "thoughts": 247, + "tool": 0, + "total": 7236 + }, + "model": "gemini-3-flash-preview", + "toolCalls": [ + { + "id": "9y4yn8tb", + "name": "write_file", + "args": { + "file_path": "hello.txt", + "content": "Hello, World!\n" + }, + "result": [ + { + "functionResponse": { + "id": "9y4yn8tb", + "name": "write_file", + "response": { + "error": "Tool \"write_file\" not found. Did you mean one of: \"read_file\", \"cli_help\", \"generalist\"?" + } + } + } + ], + "status": "error", + "timestamp": "2026-04-01T11:12:19.213Z", + "resultDisplay": "Tool \"write_file\" not found. Did you mean one of: \"read_file\", \"cli_help\", \"generalist\"?", + "description": "", + "displayName": "write_file", + "renderOutputAsMarkdown": false + }, + { + "id": "xldstmif", + "name": "write_file", + "args": { + "content": "print(\"Hello, World!\")\n", + "file_path": "hello.py" + }, + "result": [ + { + "functionResponse": { + "id": "xldstmif", + "name": "write_file", + "response": { + "error": "Tool \"write_file\" not found. Did you mean one of: \"read_file\", \"cli_help\", \"generalist\"?" + } + } + } + ], + "status": "error", + "timestamp": "2026-04-01T11:12:19.213Z", + "resultDisplay": "Tool \"write_file\" not found. Did you mean one of: \"read_file\", \"cli_help\", \"generalist\"?", + "description": "", + "displayName": "write_file", + "renderOutputAsMarkdown": false + } + ] + }, + { + "id": "003dbde9-fbff-4a6c-a232-480d815ec165", + "timestamp": "2026-04-01T11:12:49.033Z", + "type": "gemini", + "content": "I ...", + "thoughts": [ + { + "subject": "Identifying Missing Tools", + "description": "I've ...", + "timestamp": "2026-04-01T11:12:44.318Z" + }, + { + "subject": "Clarifying Tool Declarations", + "description": "I'm now r...", + "timestamp": "2026-04-01T11:12:45.959Z" + }, + { + "subject": "Reassessing Agent Capabilities", + "description": "I'm now clarifying the capabilities available to both ....", + "timestamp": "2026-04-01T11:12:47.710Z" + } + ], + "tokens": { + "input": 7318, + "output": 86, + "cached": 6081, + "thoughts": 1073, + "tool": 0, + "total": 8477 + }, + "model": "gemini-3-flash-preview", + "toolCalls": [ + { + "id": "ip8uyj0a", + "name": "generalist", + "args": { + "request": "Create two \"Hello World\" files in the current directory:\n1. hello.txt with the content \"Hello, World!\"\n2. hello.py with the content \"print('Hello, World!')\"" + }, + "result": [ + { + "functionResponse": { + "id": "ip8uyj0a", + "name": "generalist", + "response": { + "output": "Subagent 'generalist' failed. Error: You have exhausted your daily quota on this model." + } + } + } + ], + "status": "success", + "timestamp": "2026-04-01T11:14:15.882Z", + "resultDisplay": { + "isSubagentProgress": true, + "agentName": "generalist", + "recentActivity": [ + { + "id": "3a229858-3b8d-46e8-a104-672c499ec5d4", + "type": "tool_call", + "content": "activate_skill", + "displayName": "activate_skill", + "args": "{\"skill_name\":\"skill-creator\"}", + "status": "error" + }, + { + "id": "ad3a48df-b4b5-4267-afbd-ffd72807232e", + "type": "thought", + "content": "Error: Unauthorized tool call: 'activate_skill' is not available to this agent.", + "status": "error" + }, + { + "id": "feac592a-ed19-4b07-8fe0-a432d9e6e65d", + "type": "thought", + "content": "Error: TerminalQuotaError: You have exhausted your daily quota on this model.", + "status": "error" + } + ], + "state": "error" + }, + "description": "Delegating to agent 'generalist'", + "displayName": "Generalist Agent", + "renderOutputAsMarkdown": true + } + ] + }, + { + "id": "d34b91a2-bfd8-4d06-9199-cd7a42814033", + "timestamp": "2026-04-01T11:14:19.179Z", + "type": "gemini", + "content": "I will check the available tools and features to determine the best way to create the files.", + "thoughts": [], + "tokens": { + "input": 8509, + "output": 49, + "cached": 0, + "thoughts": 94, + "tool": 0, + "total": 8652 + }, + "model": "gemini-3-flash-preview", + "toolCalls": [ + { + "id": "ep9jbppf", + "name": "cli_help", + "args": { + "question": "What tools do I have access to for creating files or running shell commands?" + }, + "result": [ + { + "functionResponse": { + "id": "ep9jbppf", + "name": "cli_help", + "response": { + "output": "Subagent ..." + } + } + } + ], + "status": "success", + "timestamp": "2026-04-01T11:16:13.946Z", + "resultDisplay": "- **Answer**: ...", + "description": "Delegating to agent 'cli_help'", + "displayName": "CLI Help Agent", + "renderOutputAsMarkdown": true + } + ] + } + ], + "kind": "main" +} + +``` +#### Cursor +- `thinking` events are suppressed in print mode and will not appear in any output format. -> There is a workaround for this but will implement it later. + +### Normal assistant messages +#### Claude +```ts +export declare type SDKAssistantMessage = { + type: 'assistant'; + message: BetaMessage; + parent_tool_use_id: string | null; + error?: SDKAssistantMessageError; + uuid: UUID; + session_id: string; +}; + +export interface BetaMessage { + /** + * Unique object identifier. + * + * The format and length of IDs may change over time. + */ + id: string; + + /** + * Information about the container used in the request (for the code execution + * tool) + */ + container: BetaContainer | null; + + /** + * Content generated by the model. + * + * This is an array of content blocks, each of which has a `type` that determines + * its shape. + * + * Example: + * + * ```json + * [{ "type": "text", "text": "Hi, I'm Claude." }] + * ``` + * + * If the request input `messages` ended with an `assistant` turn, then the + * response `content` will continue directly from that last turn. You can use this + * to constrain the model's output. + * + * For example, if the input `messages` were: + * + * ```json + * [ + * { + * "role": "user", + * "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun" + * }, + * { "role": "assistant", "content": "The best answer is (" } + * ] + * ``` + * + * Then the response `content` might be: + * + * ```json + * [{ "type": "text", "text": "B)" }] + * ``` + */ + content: Array; + + /** + * Context management response. + * + * Information about context management strategies applied during the request. + */ + context_management: BetaContextManagementResponse | null; + + /** + * The model that will complete your prompt.\n\nSee + * [models](https://docs.anthropic.com/en/docs/models-overview) for additional + * details and options. + */ + model: MessagesAPI.Model; + + /** + * Conversational role of the generated message. + * + * This will always be `"assistant"`. + */ + role: 'assistant'; + + /** + * The reason that we stopped. + * + * This may be one the following values: + * + * - `"end_turn"`: the model reached a natural stopping point + * - `"max_tokens"`: we exceeded the requested `max_tokens` or the model's maximum + * - `"stop_sequence"`: one of your provided custom `stop_sequences` was generated + * - `"tool_use"`: the model invoked one or more tools + * - `"pause_turn"`: we paused a long-running turn. You may provide the response + * back as-is in a subsequent request to let the model continue. + * - `"refusal"`: when streaming classifiers intervene to handle potential policy + * violations + * + * In non-streaming mode this value is always non-null. In streaming mode, it is + * null in the `message_start` event and non-null otherwise. + */ + stop_reason: BetaStopReason | null; + + /** + * Which custom stop sequence was generated, if any. + * + * This value will be a non-null string if one of your custom stop sequences was + * generated. + */ + stop_sequence: string | null; + + /** + * Object type. + * + * For Messages, this is always `"message"`. + */ + type: 'message'; + + /** + * Billing and rate-limit usage. + * + * Anthropic's API bills and rate-limits by token counts, as tokens represent the + * underlying cost to our systems. + * + * Under the hood, the API transforms requests into a format suitable for the + * model. The model's output then goes through a parsing stage before becoming an + * API response. As a result, the token counts in `usage` will not match one-to-one + * with the exact visible content of an API request or response. + * + * For example, `output_tokens` will be non-zero, even for an empty string response + * from Claude. + * + * Total input tokens in a request is the summation of `input_tokens`, + * `cache_creation_input_tokens`, and `cache_read_input_tokens`. + */ + usage: BetaUsage; +} + +``` + +```json +{ + "parentUuid": "120b5d58-f0ff-45a2-a05d-e7b7d25d44f5", + "isSidechain": false, + "message": { + "model": "claude-opus-4-6", + "id": "msg_01BeMCoBuYsvLWfjZdGiocJC", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Let me check the project structure and any shared utilities first." + } + ], + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 3, + "cache_creation_input_tokens": 12164, + "cache_read_input_tokens": 6443, + "cache_creation": { + "ephemeral_5m_input_tokens": 12164, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 9, + "service_tier": "standard", + "inference_geo": "global" + } + }, + "requestId": "req_011CZAfVotaPvU4n42iaB7kr", + "type": "assistant", + "uuid": "21cbe29e-f965-41dd-aa26-b489553c279a", + "timestamp": "2026-03-18T13:58:53.041Z", + "userType": "external", + "cwd": "c:\\Users\\OMEN6\\Desktop\\Projects\\Paid\\ClaudeCodeUI - Siteboon\\claudecodeui", + "sessionId": "056e702c-6b8e-4727-b22b-dcea56a7ea9b", + "version": "2.1.77", + "gitBranch": "refactor/backend-rebased" + } +``` + +#### Codex +```json + { + timestamp: "2026-04-03T16:36:01.602Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "The file creation likely completed, but `git add .` failed because Git is blocking the repo as an unsafe directory due to an ownership mismatch. I’m checking what was created so I can tell you the exact state before retrying with the safe-directory fix.", + }, + ], + phase: "commentary", + }, + } +``` + + +#### Gemini +- As you can see from the full chat JSON listed above for a gemini session, you can find the normal assistant messages in `messages.content`. Note that, the assistant message should come first and then the gemini thoughts should follow for objects that have `content` and `thoughts` in the same object. + +#### Cursor +```json + { + role: "assistant", + message: { + content: [ + { + type: "text", + text: "Created both files in `scripts`:\n\n- `scripts/hello-world-1.txt`\n- `scripts/hello-world-2.txt`\n\nEach contains:\n\n`Hello, world!`\n\nIf you want these as code files instead (like `.js`, `.py`, or `.sh`), I can create those too.", + }, + ], + }, + }, +``` + The response may also contain tool use descriptions. Just use the normal `message.content` as the normal assistant message tho. + +```json + { + role: "assistant", + message: { + content: [ + { + type: "text", + text: "I’ll create two simple hello-world files in the `scripts` folder now, each with basic `Hello, world!` content.", + }, + { + type: "tool_use", + name: "ApplyPatch", + input: + "*** Begin Patch\n*** Add File: /mnt/c/Users/OMEN6/Desktop/Projects/Paid/ClaudeCodeUI - Siteboon/cloudcli-wsl-runner/scripts/hello-world-1.txt\n+Hello, world!\n*** End Patch\n", + }, + ], + }, + }, +``` + +### Error assistant message +#### Claude +```ts +export declare type SDKAssistantMessage = { + type: 'assistant'; + message: BetaMessage; + parent_tool_use_id: string | null; + error?: SDKAssistantMessageError; // can be set through here. + uuid: UUID; + session_id: string; +}; + +export declare type SDKAssistantMessageError = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens'; +``` + +#### Codex +```ts +/** Describes a non-fatal error surfaced as an item. */ +type ErrorItem = { + id: string; + type: "error"; + message: string; +}; + +/** Canonical union of thread items and their type-specific payloads. */ +type ThreadItem = AgentMessageItem | ReasoningItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | WebSearchItem | TodoListItem | ErrorItem; +``` + +#### Gemini +- didn't find anything for it. Ignore for now. +#### Cursor +- didn't find anything for it. Ignore for now. + +### Tool call request +#### Claude +```ts + +/** + * Permission callback function for controlling tool usage. + * Called before each tool execution to determine if it should be allowed. + */ +export declare type CanUseTool = (toolName: string, input: Record, options: { + /** Signaled if the operation should be aborted. */ + signal: AbortSignal; + /** + * Suggestions for updating permissions so that the user will not be + * prompted again for this tool during this session. + * + * Typically if presenting the user an option 'always allow' or similar, + * then this full set of suggestions should be returned as the + * `updatedPermissions` in the PermissionResult. + */ + suggestions?: PermissionUpdate[]; + /** + * The file path that triggered the permission request, if applicable. + * For example, when a Bash command tries to access a path outside allowed directories. + */ + blockedPath?: string; + /** Explains why this permission request was triggered. */ + decisionReason?: string; + /** + * Full permission prompt sentence rendered by the bridge (e.g. + * "Claude wants to read foo.txt"). Use this as the primary prompt + * text when present instead of reconstructing from toolName+input. + */ + title?: string; + /** + * Short noun phrase for the tool action (e.g. "Read file"), suitable + * for button labels or compact UI. + */ + displayName?: string; + /** + * Human-readable subtitle from the bridge (e.g. "Claude will have + * read and write access to files in ~/Downloads"). + */ + description?: string; + /** + * Unique identifier for this specific tool call within the assistant message. + * Multiple tool calls in the same assistant message will have different toolUseIDs. + */ + toolUseID: string; + /** If running within the context of a sub-agent, the sub-agent's ID. */ + agentID?: string; +}) => Promise; + +export declare type PermissionResult = { + behavior: 'allow'; + updatedInput?: Record; + updatedPermissions?: PermissionUpdate[]; + toolUseID?: string; + decisionClassification?: PermissionDecisionClassification; +} | { + behavior: 'deny'; + message: string; + interrupt?: boolean; + toolUseID?: string; + decisionClassification?: PermissionDecisionClassification; +}; + +/** + * Classification of this permission decision for telemetry. SDK hosts that prompt users (desktop apps, IDEs) should set this to reflect what actually happened: user_temporary for allow-once, user_permanent for always-allow (both the click and later cache hits), user_reject for deny. If unset, the CLI infers conservatively (temporary for allow, reject for deny). The vocabulary matches tool_decision OTel events (monitoring-usage docs). + */ +export declare type PermissionDecisionClassification = 'user_temporary' | 'user_permanent' | 'user_reject'; +``` + +- The below is what is actually being sent for tool use requests for the `Agent` tool type. As you can see in the table in [[#canUseTool]] section above tho, it's by default allowed so it won't ask for permission from the user. +```json +{ + "parentUuid": "21cbe29e-f965-41dd-aa26-b489553c279a", + "isSidechain": false, + "message": { + "model": "claude-opus-4-6", + "id": "msg_01BeMCoBuYsvLWfjZdGiocJC", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01WCGcF3edZiMKWz65WQxjHA", + "name": "Agent", + "input": { + "description": "Explore project structure", + "subagent_type": "Explore", + "prompt": "Quick exploration: In the project at c:\\Users\\OMEN6\\Desktop\\Projects\\Paid\\ClaudeCodeUI - Siteboon\\claudecodeui, find:\n1. Any shared utility files, especially getProjects.ts or similar\n2. The structure of server/src/modules/workspace/\n3. Any existing type definitions for workspaces\n4. How the home directory is resolved (look for os.homedir or similar patterns)\n5. Check server/src/shared/ directory contents" + }, + "caller": { "type": "direct" } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 3, + "cache_creation_input_tokens": 12164, + "cache_read_input_tokens": 6443, + "output_tokens": 249, + "server_tool_use": { + "web_search_requests": 0, + "web_fetch_requests": 0 + }, + "service_tier": "standard", + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 12164 + }, + "inference_geo": "", + "iterations": [], + "speed": "standard" + } + }, + "requestId": "req_011CZAfVotaPvU4n42iaB7kr", + "type": "assistant", + "uuid": "0375104a-1492-44f5-8bab-05d0fd00bfb7", + "timestamp": "2026-03-18T13:58:56.107Z", + "userType": "external", + "cwd": "c:\\Users\\OMEN6\\Desktop\\Projects\\Paid\\ClaudeCodeUI - Siteboon\\claudecodeui", + "sessionId": "056e702c-6b8e-4727-b22b-dcea56a7ea9b", + "version": "2.1.77", + "gitBranch": "refactor/backend-rebased" + } +``` +- The permission was not asked for the user since it was already allowed. So, you can get it from `toolUseResult`. +```json +{ + "parentUuid": "0375104a-1492-44f5-8bab-05d0fd00bfb7", + "isSidechain": false, + "promptId": "8fb10a1d-9074-4118-8cec-4873d8fdb388", + "type": "user", + "message": { + "role": "user", + "content": [ + { + "tool_use_id": "toolu_01WCGcF3edZiMKWz65WQxjHA", + "type": "tool_result", + "content": [ + { + "type": "text", + "text": "Perfect! ..." + }, + { + "type": "text", + "text": "agentId: a238716aec1e868ed (use SendMessage with to: 'a238716aec1e868ed' to continue this agent)\ntotal_tokens: 49847\ntool_uses: 49\nduration_ms: 195070" + } + ] + } + ] + }, + "uuid": "e3848887-4ae2-4237-91a2-21382556fd23", + "timestamp": "2026-03-18T14:02:11.214Z", + "toolUseResult": { + "status": "completed", + "prompt": "Quick exploration:...", + "agentId": "a238716aec1e868ed", + "content": [ + { + "type": "text", + "text": "Perfect! Now I ...." + } + ], + "totalDurationMs": 195070, + "totalTokens": 49847, + "totalToolUseCount": 49, + "usage": { + "input_tokens": 6, + "cache_creation_input_tokens": 10342, + "cache_read_input_tokens": 37779, + "output_tokens": 1720, + "server_tool_use": { + "web_search_requests": 0, + "web_fetch_requests": 0 + }, + "service_tier": "standard", + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 10342 + }, + "inference_geo": "", + "iterations": [], + "speed": "standard" + } + }, + "sourceToolAssistantUUID": "0375104a-1492-44f5-8bab-05d0fd00bfb7", + "userType": "external", + "cwd": "c:\\Users\\OMEN6\\Desktop\\Projects\\Paid\\ClaudeCodeUI - Siteboon\\claudecodeui", + "sessionId": "056e702c-6b8e-4727-b22b-dcea56a7ea9b", + "version": "2.1.77", + "gitBranch": "refactor/backend-rebased", + "slug": "rosy-snuggling-coral" + } +``` + +For file create, the request and responses are shown below: +```json + { + parentUuid: "9f2c0e9b-a7e6-47fe-a6b7-b8da1f55d95a", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01JDRRqrjsXoD4e8k9KJLnFP", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01NcaPqwqjyw4XV4Rp2t7uFT", + name: "Write", + input: { + file_path: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1\\hello.js", + content: 'console.log("Hello, World!");\n', + }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 187, + cache_read_input_tokens: 32557, + output_tokens: 274, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 187, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZgzL6AB3qwnjsvFmw3Ga", + type: "assistant", + uuid: "51dee428-71e3-4c78-a884-390b438124a8", + timestamp: "2026-04-03T14:23:47.267Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + sessionId: "323697c1-2f6a-41ec-98fb-df478e8ec02a", + version: "2.1.91", + gitBranch: "HEAD", + slug: "structured-popping-haven", + } + + -------------------------------- + { + parentUuid: "9f2c0e9b-a7e6-47fe-a6b7-b8da1f55d95a", + isSidechain: false, + promptId: "82e1a041-cf6e-4887-9f60-4b30016bae2e", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01Bsu6Umdot3mWBv3nsuxTxY", + type: "tool_result", + content: + "File created successfully at: C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1\\hello.py", + }, + ], + }, + uuid: "4e6b8203-4f8a-4c68-98a7-aa1ca16d6609", + timestamp: "2026-04-03T14:28:30.687Z", + toolUseResult: { + type: "create", + filePath: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1\\hello.py", + content: 'print("Hello, World!")\n', + structuredPatch: [], + originalFile: null, + }, + sourceToolAssistantUUID: "9f2c0e9b-a7e6-47fe-a6b7-b8da1f55d95a", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + sessionId: "323697c1-2f6a-41ec-98fb-df478e8ec02a", + version: "2.1.91", + gitBranch: "HEAD", + slug: "structured-popping-haven", + }, +``` +#### Codex + +The first is a tool call request to run a shell command. +```json + { + timestamp: "2026-04-03T16:31:19.161Z", + type: "response_item", + payload: { + type: "function_call", + name: "shell_command", + arguments: + '{"command":"New-Item -ItemType Directory -Force custom | Out-Null; Set-Content -LiteralPath custom\\\\file1.txt -Value \'custom file 1\'; Set-Content -LiteralPath custom\\\\file2.txt -Value \'custom file 2\'; git add .","workdir":"C:\\\\Users\\\\OMEN6\\\\Desktop\\\\cloudcli-test\\\\permissions-tests\\\\claude-1","timeout_ms":10000,"sandbox_permissions":"require_escalated","justification":"Do you want to allow creating two custom files and staging the workspace with git add .?"}', + call_id: "call_Cid1H2iO5aQbx9QPZiKbUmAk", + }, + }, +``` + + +#### Gemini +- NOT available in the JSON contents. +#### Cursor +- NOT available in the jsonl contents. + +### Tool call success / tool call error +#### Claude +The tool call success outputs have the following types for `toolUseResult`. They should be interpreted as "assistant messages" even though they have `user` tags for them. You can do this by noting that if a the message has the `toolUseResult` attribute, it should be considered as an assistant message with a tool call result. +```ts +export type AgentOutput = + | { + agentId: string; + agentType?: string; + content: { + type: "text"; + text: string; + }[]; + totalToolUseCount: number; + totalDurationMs: number; + totalTokens: number; + usage: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number | null; + cache_read_input_tokens: number | null; + server_tool_use: { + web_search_requests: number; + web_fetch_requests: number; + } | null; + service_tier: ("standard" | "priority" | "batch") | null; + cache_creation: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + } | null; + }; + status: "completed"; + prompt: string; + } + | { + status: "async_launched"; + /** + * The ID of the async agent + */ + agentId: string; + /** + * The description of the task + */ + description: string; + /** + * The prompt for the agent + */ + prompt: string; + /** + * Path to the output file for checking agent progress + */ + outputFile: string; + /** + * Whether the calling agent has Read/Bash tools to check progress + */ + canReadOutputFile?: boolean; + }; + +export type FileReadOutput = + | { + type: "text"; + file: { + /** + * The path to the file that was read + */ + filePath: string; + /** + * The content of the file + */ + content: string; + /** + * Number of lines in the returned content + */ + numLines: number; + /** + * The starting line number + */ + startLine: number; + /** + * Total number of lines in the file + */ + totalLines: number; + }; + } + | { + type: "image"; + file: { + /** + * Base64-encoded image data + */ + base64: string; + /** + * The MIME type of the image + */ + type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + /** + * Original file size in bytes + */ + originalSize: number; + /** + * Image dimension info for coordinate mapping + */ + dimensions?: { + /** + * Original image width in pixels + */ + originalWidth?: number; + /** + * Original image height in pixels + */ + originalHeight?: number; + /** + * Displayed image width in pixels (after resizing) + */ + displayWidth?: number; + /** + * Displayed image height in pixels (after resizing) + */ + displayHeight?: number; + }; + }; + } + | { + type: "notebook"; + file: { + /** + * The path to the notebook file + */ + filePath: string; + /** + * Array of notebook cells + */ + cells: unknown[]; + }; + } + | { + type: "pdf"; + file: { + /** + * The path to the PDF file + */ + filePath: string; + /** + * Base64-encoded PDF data + */ + base64: string; + /** + * Original file size in bytes + */ + originalSize: number; + }; + } + | { + type: "parts"; + file: { + /** + * The path to the PDF file + */ + filePath: string; + /** + * Original file size in bytes + */ + originalSize: number; + /** + * Number of pages extracted + */ + count: number; + /** + * Directory containing extracted page images + */ + outputDir: string; + }; + } + | { + type: "file_unchanged"; + file: { + /** + * The path to the file + */ + filePath: string; + }; + }; + +export interface FileEditOutput { + /** + * The file path that was edited + */ + filePath: string; + /** + * The original string that was replaced + */ + oldString: string; + /** + * The new string that replaced it + */ + newString: string; + /** + * The original file contents before editing + */ + originalFile: string; + /** + * Diff patch showing the changes + */ + structuredPatch: { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; + }[]; + /** + * Whether the user modified the proposed changes + */ + userModified: boolean; + /** + * Whether all occurrences were replaced + */ + replaceAll: boolean; + gitDiff?: { + filename: string; + status: "modified" | "added"; + additions: number; + deletions: number; + changes: number; + patch: string; + /** + * GitHub owner/repo when available + */ + repository?: string | null; + }; +} +export interface FileWriteOutput { + /** + * Whether a new file was created or an existing file was updated + */ + type: "create" | "update"; + /** + * The path to the file that was written + */ + filePath: string; + /** + * The content that was written to the file + */ + content: string; + /** + * Diff patch showing the changes + */ + structuredPatch: { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; + }[]; + /** + * The original file content before the write (null for new files) + */ + originalFile: string | null; + gitDiff?: { + filename: string; + status: "modified" | "added"; + additions: number; + deletions: number; + changes: number; + patch: string; + /** + * GitHub owner/repo when available + */ + repository?: string | null; + }; +} +export interface GlobOutput { + /** + * Time taken to execute the search in milliseconds + */ + durationMs: number; + /** + * Total number of files found + */ + numFiles: number; + /** + * Array of file paths that match the pattern + */ + filenames: string[]; + /** + * Whether results were truncated (limited to 100 files) + */ + truncated: boolean; +} +export interface GrepOutput { + mode?: "content" | "files_with_matches" | "count"; + numFiles: number; + filenames: string[]; + content?: string; + numLines?: number; + numMatches?: number; + appliedLimit?: number; + appliedOffset?: number; +} +export interface ReadMcpResourceOutput { + contents: { + /** + * Resource URI + */ + uri: string; + /** + * MIME type of the content + */ + mimeType?: string; + /** + * Text content of the resource + */ + text?: string; + /** + * Path where binary blob content was saved + */ + blobSavedTo?: string; + }[]; +} +export interface TodoWriteOutput { + /** + * The todo list before the update + */ + oldTodos: { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; + }[]; + /** + * The todo list after the update + */ + newTodos: { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; + }[]; + verificationNudgeNeeded?: boolean; +} +export interface WebFetchOutput { + /** + * Size of the fetched content in bytes + */ + bytes: number; + /** + * HTTP response code + */ + code: number; + /** + * HTTP response code text + */ + codeText: string; + /** + * Processed result from applying the prompt to the content + */ + result: string; + /** + * Time taken to fetch and process the content + */ + durationMs: number; + /** + * The URL that was fetched + */ + url: string; +} +export interface WebSearchOutput { + /** + * The search query that was executed + */ + query: string; + /** + * Search results and/or text commentary from the model + */ + results: ( + | { + /** + * ID of the tool use + */ + tool_use_id: string; + /** + * Array of search hits + */ + content: { + /** + * The title of the search result + */ + title: string; + /** + * The URL of the search result + */ + url: string; + }[]; + } + | string + )[]; + /** + * Time taken to complete the search operation + */ + durationSeconds: number; +} +export interface AskUserQuestionOutput { + /** + * The questions that were asked + */ + questions: { + /** + * The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?" + */ + question: string; + /** + * Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach". + */ + header: string; + /** + * The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically. + * + * @minItems 2 + * @maxItems 4 + */ + options: + | [ + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + } + ] + | [ + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + } + ] + | [ + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + }, + { + /** + * The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice. + */ + label: string; + /** + * Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications. + */ + description: string; + /** + * Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format. + */ + preview?: string; + } + ]; + /** + * Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive. + */ + multiSelect: boolean; + }[]; + /** + * The answers provided by the user (question text -> answer string; multi-select answers are comma-separated) + */ + answers: { + [k: string]: string; + }; + /** + * Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text. + */ + annotations?: { + [k: string]: { + /** + * The preview content of the selected option, if the question used previews. + */ + preview?: string; + /** + * Free-text notes the user added to their selection. + */ + notes?: string; + }; + }; +} +``` + +For tool call errors, the result is something like below: +```json + { + parentUuid: "e4a8a6f4-fe85-49c3-87a7-4abe91561d16", + isSidechain: false, + promptId: "28f5ceb3-1501-4553-b713-818bae551415", + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + content: + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.", + is_error: true, + tool_use_id: "toolu_01DFg7zx5vaBDL7JiX1x3Aut", + }, + ], + }, + uuid: "85a77a79-ce00-445f-a953-d20f779cdabd", + timestamp: "2026-04-03T14:54:42.201Z", + toolUseResult: "User rejected tool use", + sourceToolAssistantUUID: "e4a8a6f4-fe85-49c3-87a7-4abe91561d16", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + sessionId: "323697c1-2f6a-41ec-98fb-df478e8ec02a", + version: "2.1.91", + gitBranch: "HEAD", + slug: "structured-popping-haven", + } + +``` + +- After the above another user message without a `toolUseResult` is given. This should be part of interpreted as a user message. +```json + { + parentUuid: "85a77a79-ce00-445f-a953-d20f779cdabd", + isSidechain: false, + promptId: "28f5ceb3-1501-4553-b713-818bae551415", + type: "user", + message: { + role: "user", + content: [ + { type: "text", text: "[Request interrupted by user for tool use]" }, + ], + }, + uuid: "eb2783be-0397-477a-8b92-d4017bf12394", + timestamp: "2026-04-03T14:54:42.201Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + sessionId: "323697c1-2f6a-41ec-98fb-df478e8ec02a", + version: "2.1.91", + gitBranch: "HEAD", + slug: "structured-popping-haven", + }, + +``` + + + + + +#### Codex +- After a tool is rejected (by the user), you'll see the following: +```json + { + timestamp: "2026-04-03T16:26:01.251Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: "\nThe user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.\n", + }, + ], + }, + }, +``` +- For a failed tool call (while the session is still running), the following 2 json events are sent. +```json + { + timestamp: "2026-04-03T16:35:53.517Z", + type: "event_msg", + payload: { + type: "exec_command_end", + call_id: "call_Cid1H2iO5aQbx9QPZiKbUmAk", + turn_id: "019d542f-25e5-7002-8661-94420fa80101", + command: [ + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-Command", + "New-Item -ItemType Directory -Force custom | Out-Null; Set-Content -LiteralPath custom\\file1.txt -Value 'custom file 1'; Set-Content -LiteralPath custom\\file2.txt -Value 'custom file 2'; git add .", + ], + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + parsed_cmd: [ + { + type: "unknown", + cmd: "New-Item -ItemType Directory -Force custom | Out-Null; Set-Content -LiteralPath custom\\file1.txt -Value 'custom file 1'; Set-Content -LiteralPath custom\\file2.txt -Value 'custom file 2'; git add .", + }, + ], + source: "agent", + stdout: "", + stderr: "", + aggregated_output: + "fatal: detected dubious ownership in repository at 'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1'\n'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1/.git' is owned by:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1028'\nbut the current user is:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1001'\nTo add an exception for this directory, call:\n\n\tgit config --global --add safe.directory C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1\n", + exit_code: 1, + duration: { secs: 0, nanos: 304366900 }, + formatted_output: "", + status: "failed", // notice this status! + }, + }, + { + timestamp: "2026-04-03T16:35:53.521Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_Cid1H2iO5aQbx9QPZiKbUmAk", + output: + "Exit code: 1\nWall time: 0.3 seconds\nOutput:\nfatal: detected dubious ownership in repository at 'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1'\n'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1/.git' is owned by:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1028'\nbut the current user is:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1001'\nTo add an exception for this directory, call:\n\n\tgit config --global --add safe.directory C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1\n", + }, + } + + { + timestamp: "2026-04-03T16:35:53.521Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_Cid1H2iO5aQbx9QPZiKbUmAk", + output: + "Exit code: 1\nWall time: 0.3 seconds\nOutput:\nfatal: detected dubious ownership in repository at 'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1'\n'C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1/.git' is owned by:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1028'\nbut the current user is:\n\t'S-1-5-21-3633208553-2448371058-3262728859-1001'\nTo add an exception for this directory, call:\n\n\tgit config --global --add safe.directory C:/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/claude-1\n", + }, + }, + + +``` +- For a succeeded tool call, 2 `json` items are sent as well. +```json + + { + timestamp: "2026-04-03T16:36:02.245Z", + type: "event_msg", + payload: { + type: "exec_command_end", + call_id: "call_aLVvwVHBPnwI9X5dXIJuQ8Ss", + turn_id: "019d542f-25e5-7002-8661-94420fa80101", + command: [ + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-Command", + "Get-ChildItem -Recurse -Force", + ], + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\permissions-tests\\claude-1", + parsed_cmd: [{ type: "unknown", cmd: "Get-ChildItem -Recurse -Force" }], + source: "agent", + stdout: "", + stderr: "", + aggregated_output: + "\r\n\r\n...", + exit_code: 0, + duration: { secs: 0, nanos: 639176800 }, + formatted_output: "", + status: "completed", + }, + } + + { + timestamp: "2026-04-03T16:36:02.246Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_aLVvwVHBPnwI9X5dXIJuQ8Ss", + output: + "Exit code: ...", + }, + } +``` +#### Gemini +- You can find each of the tool calls from the `messages[some-index].toolCalls` array. Then for each tool call, `toolCall.status` to get either the "success" or "error" type. error means failed tool call. to get more description about the tool call, use `name`, `displayName`, `args`, `result` and `resultDisplay` + +#### Cursor +- Get it from the object with`type: "tool_use"` +```json + { + role: "assistant", + message: { + content: [ + { + type: "text", + text: "I’ll create two simple hello-world files in the `scripts` folder now, each with basic `Hello, world!` content.", + }, + { + type: "tool_use", + name: "ApplyPatch", + input: + "*** Begin Patch\n*** Add File: /mnt/c/Users/OMEN6/Desktop/Projects/Paid/ClaudeCodeUI - Siteboon/cloudcli-wsl-runner/scripts/hello-world-1.txt\n+Hello, world!\n*** End Patch\n", + }, + ], + }, + }, +``` + +Another example `json` with a bunch of tools +```json + { + role: "assistant", + message: { + content: [ + { + type: "text", + text: "Checking the workspace, then creating two hello-world files.\n\n[REDACTED]", + }, + { + type: "tool_use", + name: "Glob", + input: { + target_directory: + "/mnt/c/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/cursor/cursor-1", + glob_pattern: "**/*", + }, + }, + { + type: "tool_use", + name: "Write", + input: { + path: "/mnt/c/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/cursor/cursor-1/hello_world.js", + contents: 'console.log("Hello, world!");\n', + }, + }, + { + type: "tool_use", + name: "StrReplace", + input: { + path: "/home/linux-learner/.cursor/plans/Initialize Git repo-dc1b3bd9.plan.md", + old_string: "# Initialize Git in the workspace\n\n## Goal", + new_string: + "# Initialize Git in the workspace\n\n## Explanation\n\n- **Why this keeps coming ...ing the plan, not execution.\n\n## Goal", + }, + }, + { + type: "tool_use", + name: "Shell", + input: { + command: + "cd /mnt/c/Users/OMEN6/Desktop/cloudcli-test/permissions-tests/cursor/cursor-1 && git init", + description: "Initialize Git repository in workspace root", + }, + }, + ], + }, + }, +``` + +- Tool call rejections are not available in the json. +### Todo/task list related +- In the formatted response, if there is a progress indicator for the todo list (like for claude and codex), include a separate boolean field called `has_progress_indicator` which is set to true. For cursor, it should be set to false. +#### Claude +The complete JSONL responses sent for a task list for claude are shown below: +```json + { + parentUuid: "ed53d297-ee9b-4884-a87c-e4864d615d5c", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: + "thanks. can u create a todo list for creating basic 2 hello world files and then implement them one by one. both should be js and python", + }, + uuid: "ed3b5ae6-65d7-4b4a-acfe-3c0237c6ac1d", + timestamp: "2026-04-05T06:04:19.598Z", + permissionMode: "default", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "2d9be038-7f3f-4c2a-ac06-c694288c6948", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01RhEJ7qPdR5Dg1JaxR89mgU", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01ERZh8eT2Wb7ns4eH3DRHd8", + name: "TaskCreate", + input: { + subject: "Create JavaScript hello world file", + description: + 'Create a basic JavaScript file that prints "Hello, World!" to the console', + activeForm: "Creating JS hello world file", + }, + caller: { type: "direct" }, + }, + ], + stop_reason: null, + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 9, + cache_creation_input_tokens: 3547, + cache_read_input_tokens: 32609, + cache_creation: { + ephemeral_5m_input_tokens: 3547, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 41, + service_tier: "standard", + inference_geo: "not_available", + }, + }, + requestId: "req_011CZk7sxDtzutaRny5r6Vtc", + type: "assistant", + uuid: "72036441-f063-46ac-9f0c-5c621736a344", + timestamp: "2026-04-05T06:04:23.995Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "72036441-f063-46ac-9f0c-5c621736a344", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01ERZh8eT2Wb7ns4eH3DRHd8", + type: "tool_result", + content: + "Task #1 created successfully: Create JavaScript hello world file", + }, + ], + }, + uuid: "3ee09e04-59af-4ce1-854b-19bc9cb4c80a", + timestamp: "2026-04-05T06:04:24.090Z", + toolUseResult: { + task: { id: "1", subject: "Create JavaScript hello world file" }, + }, + sourceToolAssistantUUID: "72036441-f063-46ac-9f0c-5c621736a344", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "3ee09e04-59af-4ce1-854b-19bc9cb4c80a", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01RhEJ7qPdR5Dg1JaxR89mgU", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01RfgKscH772CzLKP5BqEjK6", + name: "TaskCreate", + input: { + subject: "Create Python hello world file", + description: + 'Create a basic Python file that prints "Hello, World!" to the console', + activeForm: "Creating Python hello world file", + }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 9, + cache_creation_input_tokens: 3547, + cache_read_input_tokens: 32609, + output_tokens: 306, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 3547, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk7sxDtzutaRny5r6Vtc", + type: "assistant", + uuid: "16a3e529-7f53-4276-a183-0143dca6323d", + timestamp: "2026-04-05T06:04:24.210Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "16a3e529-7f53-4276-a183-0143dca6323d", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01RfgKscH772CzLKP5BqEjK6", + type: "tool_result", + content: + "Task #2 created successfully: Create Python hello world file", + }, + ], + }, + uuid: "a00820f1-db34-40e2-8996-c8a6b20db17b", + timestamp: "2026-04-05T06:04:24.263Z", + toolUseResult: { + task: { id: "2", subject: "Create Python hello world file" }, + }, + sourceToolAssistantUUID: "16a3e529-7f53-4276-a183-0143dca6323d", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "78347791-bd13-47d0-9797-81f9ebd22106", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01DMaxJcoe7A7HhKt9CjWcuf", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01NFgo8id39yShr7GUhcpr3h", + name: "TaskUpdate", + input: { taskId: "1", status: "in_progress" }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 13, + cache_creation_input_tokens: 394, + cache_read_input_tokens: 36156, + output_tokens: 111, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 394, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk7tEGYt2aEE3driVyLh", + type: "assistant", + uuid: "7aac5f46-4421-4e79-8c29-15c994bdc2eb", + timestamp: "2026-04-05T06:04:25.939Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "7aac5f46-4421-4e79-8c29-15c994bdc2eb", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01NFgo8id39yShr7GUhcpr3h", + type: "tool_result", + content: "Updated task #1 status", + }, + ], + }, + uuid: "3068b906-ab7c-4d79-b21e-998f006c38c9", + timestamp: "2026-04-05T06:04:25.975Z", + toolUseResult: { + success: true, + taskId: "1", + updatedFields: ["status"], + statusChange: { from: "pending", to: "in_progress" }, + verificationNudgeNeeded: false, + }, + sourceToolAssistantUUID: "7aac5f46-4421-4e79-8c29-15c994bdc2eb", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "1ca4441c-2ed0-41ac-b79d-603f2545ef04", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01McgVyDTNGcW7NQihMMGvbJ", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01X65utf94brsRwLuES4wD21", + name: "Write", + input: { + file_path: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.js", + content: 'console.log("Hello, World!");\n', + }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 12, + cache_creation_input_tokens: 531, + cache_read_input_tokens: 36156, + output_tokens: 121, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 531, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk7tLzcYf5Byea21n7qu", + type: "assistant", + uuid: "ae7cf1f6-b6c0-4163-8024-09863185b518", + timestamp: "2026-04-05T06:04:27.823Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "ae7cf1f6-b6c0-4163-8024-09863185b518", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01X65utf94brsRwLuES4wD21", + type: "tool_result", + content: + "File created successfully at: C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.js", + }, + ], + }, + uuid: "08197aad-ab17-433f-a181-0d96488bd4ff", + timestamp: "2026-04-05T06:07:23.731Z", + toolUseResult: { + type: "create", + filePath: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.js", + content: 'console.log("Hello, World!");\n', + structuredPatch: [], + originalFile: null, + }, + sourceToolAssistantUUID: "ae7cf1f6-b6c0-4163-8024-09863185b518", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "89ede0c4-21c9-43f2-8a4b-3067c8ed034b", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01APNLMZvLZ38ar6pRzTMX15", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01SWtkbwLC1JUDqLnZB94ce4", + name: "TaskUpdate", + input: { taskId: "1", status: "completed" }, + caller: { type: "direct" }, + }, + ], + stop_reason: null, + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 12, + cache_creation_input_tokens: 171, + cache_read_input_tokens: 36687, + cache_creation: { + ephemeral_5m_input_tokens: 171, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 26, + service_tier: "standard", + inference_geo: "not_available", + }, + }, + requestId: "req_011CZk87XS31nmwBD927E43V", + type: "assistant", + uuid: "f9b0b43d-d88e-4a9d-8c37-d21ef70f09be", + timestamp: "2026-04-05T06:07:26.786Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "f9b0b43d-d88e-4a9d-8c37-d21ef70f09be", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01SWtkbwLC1JUDqLnZB94ce4", + type: "tool_result", + content: "Updated task #1 status", + }, + ], + }, + uuid: "ecc8cc35-50e3-48a0-9403-2733b9388581", + timestamp: "2026-04-05T06:07:26.928Z", + toolUseResult: { + success: true, + taskId: "1", + updatedFields: ["status"], + statusChange: { from: "in_progress", to: "completed" }, + verificationNudgeNeeded: false, + }, + sourceToolAssistantUUID: "f9b0b43d-d88e-4a9d-8c37-d21ef70f09be", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "ecc8cc35-50e3-48a0-9403-2733b9388581", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01APNLMZvLZ38ar6pRzTMX15", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01VqXeiotKExqk3KKmY6Nyo3", + name: "TaskUpdate", + input: { taskId: "2", status: "in_progress" }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 12, + cache_creation_input_tokens: 171, + cache_read_input_tokens: 36687, + output_tokens: 156, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 171, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk87XS31nmwBD927E43V", + type: "assistant", + uuid: "d13f1bc0-d4c8-4471-9578-c2af1e11f0c2", + timestamp: "2026-04-05T06:07:26.997Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "d13f1bc0-d4c8-4471-9578-c2af1e11f0c2", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01VqXeiotKExqk3KKmY6Nyo3", + type: "tool_result", + content: "Updated task #2 status", + }, + ], + }, + uuid: "22031a82-36f1-4415-8aea-b17728f5b92f", + timestamp: "2026-04-05T06:07:27.107Z", + toolUseResult: { + success: true, + taskId: "2", + updatedFields: ["status"], + statusChange: { from: "pending", to: "in_progress" }, + verificationNudgeNeeded: false, + }, + sourceToolAssistantUUID: "d13f1bc0-d4c8-4471-9578-c2af1e11f0c2", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "66bd0e2c-6d97-4d39-a1a6-6d1716843604", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01VPRPQCGarBBfJGUN7sW8xW", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01W8QZG9vHTYNQULAVBYWSX5", + name: "Write", + input: { + file_path: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.py", + content: 'print("Hello, World!")\n', + }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 13, + cache_creation_input_tokens: 235, + cache_read_input_tokens: 36858, + output_tokens: 119, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 235, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk87hSnfEz7dYAnrJqP3", + type: "assistant", + uuid: "3d99f616-5bc6-4c52-880c-7bbf5d54636f", + timestamp: "2026-04-05T06:07:28.814Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "3d99f616-5bc6-4c52-880c-7bbf5d54636f", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01W8QZG9vHTYNQULAVBYWSX5", + type: "tool_result", + content: + "File created successfully at: C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.py", + }, + ], + }, + uuid: "c750d486-f03e-4179-b4bb-a50c2f6f9e6e", + timestamp: "2026-04-05T06:07:36.308Z", + toolUseResult: { + type: "create", + filePath: + "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test\\hello-world.py", + content: 'print("Hello, World!")\n', + structuredPatch: [], + originalFile: null, + }, + sourceToolAssistantUUID: "3d99f616-5bc6-4c52-880c-7bbf5d54636f", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "2cf178ac-ab73-4bae-910e-2be6d0a4419f", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01HtpR8ZjNS1viic63CnPZYH", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_015jwWe9vMX6BbZpwXvwXGmw", + name: "TaskUpdate", + input: { taskId: "2", status: "completed" }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 12, + cache_creation_input_tokens: 170, + cache_read_input_tokens: 37093, + output_tokens: 95, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 170, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk88SepVwvTAQP8sJgNw", + type: "assistant", + uuid: "e80b9903-66e1-40a1-9c28-945d09672ae6", + timestamp: "2026-04-05T06:07:38.703Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "e80b9903-66e1-40a1-9c28-945d09672ae6", + isSidechain: false, + promptId: "1b289ada-272c-4684-ad84-7e2f62217a7a", + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_015jwWe9vMX6BbZpwXvwXGmw", + type: "tool_result", + content: "Updated task #2 status", + }, + ], + }, + uuid: "bcf61a92-e3f7-42fe-a925-0c9a23b01fd0", + timestamp: "2026-04-05T06:07:38.743Z", + toolUseResult: { + success: true, + taskId: "2", + updatedFields: ["status"], + statusChange: { from: "in_progress", to: "completed" }, + verificationNudgeNeeded: false, + }, + sourceToolAssistantUUID: "e80b9903-66e1-40a1-9c28-945d09672ae6", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, + { + parentUuid: "377d8f55-2f39-4e7b-bb5a-397f9151804d", + isSidechain: false, + message: { + model: "claude-haiku-4-5-20251001", + id: "msg_01XiKayuDZmurk279M36YNwt", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: 'Done! ✓\n\n**Created files:**\n- `hello-world.js` — JavaScript file that prints "Hello, World!"\n- `hello-world.py` — Python file that prints "Hello, World!"\n\nBoth tasks have been completed. You can run them with:\n```bash\nnode hello-world.js # for JavaScript\npython hello-world.py # for Python\n```', + }, + ], + stop_reason: "end_turn", + stop_sequence: null, + stop_details: null, + usage: { + input_tokens: 12, + cache_creation_input_tokens: 120, + cache_read_input_tokens: 37263, + output_tokens: 114, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 120, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + requestId: "req_011CZk88ZFhY6PF6tf8eoj3j", + type: "assistant", + uuid: "b424805a-008d-45a0-a73f-23cda4cafcd8", + timestamp: "2026-04-05T06:07:40.941Z", + userType: "external", + entrypoint: "cli", + cwd: "C:\\Users\\OMEN6\\Desktop\\cloudcli-test\\claude-test", + sessionId: "c7c4a778-1795-4f07-b6a4-89f70db8ccde", + version: "2.1.91", + gitBranch: "main", + slug: "polished-humming-stonebraker", + }, +``` + +Some types that maybe useful: +```ts +export interface TodoWriteInput { + /** + * The updated todo list + */ + todos: { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; + }[]; +} + +export interface TodoWriteOutput { + /** + * The todo list before the update + */ + oldTodos: { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; + }[]; + /** + * The todo list after the update + */ + newTodos: { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; + }[]; + verificationNudgeNeeded?: boolean; +} +``` +#### Codex +```json + { + timestamp: "2026-04-03T20:33:15.531Z", + type: "response_item", + payload: { + type: "function_call", + name: "update_plan", + arguments: + '{"plan":[{"step":"Check current workspace contents for conflicts","status":"in_progress"},{"step":"Create a short todo list for three hello-world files","status":"pending"},{"step":"Add the three files with basic hello-world content","status":"pending"},{"step":"Verify the files were created","status":"pending"}]}', + call_id: "call_pGG1ISfS9zeR0SiKEstZj1ly", + }, + } +``` + +- After the first task is done, the "status" in the arguments gets updated. +```json + { + timestamp: "2026-04-03T20:33:29.911Z", + type: "response_item", + payload: { + type: "function_call", + name: "update_plan", + arguments: + '{"plan":[{"step":"Check current workspace contents for conflicts","status":"completed"},{"step":"Create a short todo list for three hello-world files","status":"completed"},{"step":"Add the three files with basic hello-world content","status":"in_progress"},{"step":"Verify the files were created","status":"pending"}]}', + call_id: "call_7OR4VdPLDEL2X5d9R8cfBxxM", + }, + }, + +``` +#### Gemini +- Ignore For now! +#### Cursor +- Get the plan and the todo list from here. +```json + { + role: "assistant", + message: { + content: [ + { + type: "text", + text: "Workspace contains `hello_world.py` and `hello_world.js`; no `.git` visible (dot directories may be hidden from the search). Planning to initialize Git there.\n\n[REDACTED]", + }, + { + type: "tool_use", + name: "CreatePlan", + input: { + name: "Initialize Git repo", + overview: + "Run `git init` in your project directory so the hello world files are under version control. No code or config changes are required beyond creating the repository metadata.", + plan: "# Initialize Git in the workspace\n\n## Goal\n\nRecreate what was blocked in Ask mode: .... unset.\n", + todos: [ + { + id: "git-init", + content: "Run `git init` in workspace root and confirm success", + }, + ], + }, + }, + ], + }, + }, +``` + +### Session start (Shared by all providers) +This is a custom type with the following types: +```json +{ + "sessionId": "...", + "sessionStatus": "STARTED" +} +``` +We have it whenever we begin a process. + +### Session completed (Shared by all Providers) +#### Claude and Codex +- When the query has completely received every message, we'll have this custom JSON session complete message. +```json +{ + "sessionId": "...", + "sessionStatus": "COMPLETED" +} +``` +#### Gemini AND Cursor +- When the spawned process is done, we know that the session is completed. So, we also have the above JSON message. + +### Session Interrupted (Shared by all providers) +#### Claude +- After we call, ` await session.instance.interrupt();` we should have the following custom JSON abort message. +```json +{ + "sessionId": "...", + "sessionStatus": "SESSION_ABORTED" +} +``` + +#### Codex +- When the abort controller signal is recieved, this custom jsonl is sent. + +```json +{ + "sessionId": "...", + "sessionStatus": "SESSION_ABORTED" +} +``` + +#### Gemini AND Cursor +- When the spawned process is aborted, we know that the session was terminated forcefully. +We use this custom JSON type. +```json +{ + "sessionId": "...", + "sessionStatus": "SESSION_ABORTED" +} +``` + +# Web and notifications emitting chat types (for all) +-> session completed +-> permission requests (can use tool for claude) + + diff --git a/package.json b/package.json index 9e30816d..2132e138 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "preview": "vite preview", "typecheck:client": "tsc --noEmit -p tsconfig.json", "typecheck:server": "tsc --noEmit -p server/tsconfig.json", - "test:server": "tsx --tsconfig server/tsconfig.json --test server/src/modules/llm/llm-session-processor.service.test.ts server/src/modules/llm/llm-unifier.providers.test.ts server/src/modules/llm/llm-unifier.sessions.test.ts server/src/modules/llm/llm-unifier.images.test.ts server/src/modules/llm/llm-unifier.mcp.test.ts server/src/modules/llm/llm-unifier.skills.test.ts", + "test:server": "tsx --tsconfig server/tsconfig.json --test server/src/modules/llm/llm-session-processor.service.test.ts server/src/modules/llm/llm-unifier.providers.test.ts server/src/modules/llm/llm-unifier.sessions.test.ts server/src/modules/llm/llm-unifier.images.test.ts server/src/modules/llm/llm-unifier.mcp.test.ts server/src/modules/llm/llm-unifier.skills.test.ts server/src/modules/llm/llm-unifier.messages.test.ts", "verify:server": "npm run typecheck:server && npm run test:server && npm run server:build", "typecheck": "npm run typecheck:client && npm run typecheck:server", "lint": "eslint src/", diff --git a/server/src/modules/llm/llm-unifier.messages.test.ts b/server/src/modules/llm/llm-unifier.messages.test.ts new file mode 100644 index 00000000..ed77fa1c --- /dev/null +++ b/server/src/modules/llm/llm-unifier.messages.test.ts @@ -0,0 +1,337 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { llmMessagesUnifier } from './messages-unifier.service.js'; + +/** + * This test covers helper-3 Claude normalization: user/assistant/thinking/tool-use/tool-result/error. + */ +test('llmMessagesUnifier normalizes claude message categories', () => { + const sessionId = 'claude-session-1'; + + const thinking = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'assistant', + timestamp: '2026-04-06T10:00:00.000Z', + message: { + content: [ + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'Assistant response' }, + { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: 'a.txt' } }, + ], + }, + }); + assert.equal(thinking[0]?.type, 'thinking_message'); + assert.equal(thinking[0]?.text, 'Thinking'); + assert.equal(thinking[1]?.type, 'assistant_message'); + assert.equal(thinking[2]?.type, 'tool_use_request'); + + const user = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'user', + message: { + content: [ + { type: 'text', text: 'hello there' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'image-b64', + }, + }, + ], + }, + }); + assert.equal(user[0]?.type, 'user_message'); + assert.equal(user[0]?.text, 'hello there'); + assert.deepEqual(user[0]?.images, ['image-b64']); + + const toolResult = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'user', + toolUseResult: { success: false, reason: 'denied' }, + }); + assert.equal(toolResult[0]?.type, 'tool_call_error'); + + const toolResultSuccess = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'user', + toolUseResult: { type: 'create', filePath: 'hello.py' }, + }); + assert.equal(toolResultSuccess[0]?.type, 'tool_call_success'); + + const todo = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'toolu_todo', + name: 'TaskUpdate', + input: { taskId: '1', status: 'in_progress' }, + }, + ], + }, + }); + assert.equal(todo[0]?.type, 'todo_task_list'); + assert.equal(todo[0]?.has_progress_indicator, true); + + const assistantError = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'assistant', + error: 'rate_limit', + message: { content: [] }, + }); + assert.equal(assistantError[0]?.type, 'assistant_error_message'); +}); + +/** + * This test covers helper-3 Codex normalization: user_message, reasoning fallback, tool request/success/error, todo plan updates. + */ +test('llmMessagesUnifier normalizes codex message categories', () => { + const sessionId = 'codex-session-1'; + + const user = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'event_msg', + payload: { + type: 'user_message', + message: 'run command', + local_images: ['a.png'], + images: ['b.png'], + }, + }); + assert.equal(user[0]?.type, 'user_message'); + assert.deepEqual(user[0]?.images, ['a.png', 'b.png']); + + const reasoning = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'reasoning', + summary: [], + }, + }); + assert.equal(reasoning[0]?.type, 'thinking_message'); + assert.equal(reasoning[0]?.text, 'Reasoning'); + + const toolRequest = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'function_call', + name: 'shell_command', + arguments: '{"command":"echo hi"}', + call_id: 'call_1', + }, + }); + assert.equal(toolRequest[0]?.type, 'tool_use_request'); + + const assistantMessage = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Command finished' }], + }, + }); + assert.equal(assistantMessage[0]?.type, 'assistant_message'); + assert.equal(assistantMessage[0]?.text, 'Command finished'); + + const todo = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'function_call', + name: 'update_plan', + arguments: '{"plan":[{"step":"A","status":"in_progress"}]}', + call_id: 'call_2', + }, + }); + assert.equal(todo[0]?.type, 'todo_task_list'); + assert.equal(todo[0]?.has_progress_indicator, true); + + const toolError = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'event_msg', + payload: { + type: 'exec_command_end', + status: 'failed', + call_id: 'call_3', + }, + }); + assert.equal(toolError[0]?.type, 'tool_call_error'); + + const toolSuccess = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call_4', + output: 'Exit code: 0\nWall time: 0.1 seconds', + }, + }); + assert.equal(toolSuccess[0]?.type, 'tool_call_success'); + + const interruptedTurn = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '\nInterrupted\n' }], + }, + }); + assert.equal(interruptedTurn[0]?.type, 'session_interrupted'); + + const payloadError = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'response_item', + payload: { + type: 'error', + message: 'codex payload error', + }, + }); + assert.equal(payloadError[0]?.type, 'assistant_error_message'); + + const streamError = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + type: 'error', + message: 'codex stream error', + }); + assert.equal(streamError[0]?.type, 'assistant_error_message'); +}); + +/** + * This test covers helper-3 Gemini normalization from JSON history: user/assistant/thought/tool-call success and error. + */ +test('llmMessagesUnifier normalizes gemini history categories', () => { + const sessionId = 'gemini-session-1'; + const messages = llmMessagesUnifier.normalizeUnknown('gemini', sessionId, { + sessionId, + messages: [ + { + type: 'user', + timestamp: '2026-04-01T10:00:00.000Z', + content: [{ text: 'create files' }], + }, + { + type: 'gemini', + timestamp: '2026-04-01T10:00:01.000Z', + content: 'I will do it', + thoughts: [{ subject: 'Planning', description: 'Thinking path' }], + toolCalls: [ + { id: 't1', name: 'write_file', displayName: 'Write File', status: 'success' }, + { id: 't2', name: 'write_file', status: 'error' }, + ], + }, + ], + }); + + assert.ok(messages.some((message) => message.type === 'user_message')); + assert.ok(messages.some((message) => message.type === 'assistant_message')); + assert.ok(messages.some((message) => message.type === 'thinking_message')); + assert.ok(messages.some((message) => message.type === 'tool_call_success')); + assert.ok(messages.some((message) => message.type === 'tool_call_error')); + + const assistantIndex = messages.findIndex((message) => message.type === 'assistant_message'); + const thinkingIndex = messages.findIndex((message) => message.type === 'thinking_message'); + assert.ok(assistantIndex >= 0); + assert.ok(thinkingIndex > assistantIndex); + + const successfulTool = messages.find((message) => message.type === 'tool_call_success'); + assert.equal(successfulTool?.toolName, 'Write File'); +}); + +/** + * This test covers helper-3 Cursor normalization: strip user_query tags and parse CreatePlan as todo with no progress indicator. + */ +test('llmMessagesUnifier normalizes cursor categories and strips user_query tags', () => { + const sessionId = 'cursor-session-1'; + const user = llmMessagesUnifier.normalizeUnknown('cursor', sessionId, { + role: 'user', + message: { + content: [{ type: 'text', text: '\nhello world\n' }], + }, + }); + assert.equal(user[0]?.type, 'user_message'); + assert.equal(user[0]?.text, 'hello world'); + + const assistant = llmMessagesUnifier.normalizeUnknown('cursor', sessionId, { + role: 'assistant', + message: { + content: [ + { type: 'text', text: 'Starting work' }, + { + type: 'tool_use', + name: 'CreatePlan', + input: { + todos: [{ id: '1', content: 'Do it' }], + }, + }, + { + type: 'tool_use', + name: 'ApplyPatch', + input: { + patch: '*** Begin Patch', + }, + }, + ], + }, + }); + + assert.ok(assistant.some((message) => message.type === 'assistant_message')); + const todoMessage = assistant.find((message) => message.type === 'todo_task_list'); + assert.equal(todoMessage?.has_progress_indicator, false); + assert.ok(assistant.some((message) => message.type === 'tool_call_success')); +}); + +/** + * This test covers shared session status normalization: started/completed/interrupted payloads. + */ +test('llmMessagesUnifier normalizes shared session status events', () => { + const sessionId = 'shared-session-1'; + const started = llmMessagesUnifier.normalizeUnknown('codex', sessionId, { + sessionId, + sessionStatus: 'STARTED', + }); + assert.equal(started[0]?.type, 'session_started'); + + const completed = llmMessagesUnifier.normalizeUnknown('gemini', sessionId, { + sessionId, + sessionStatus: 'COMPLETED', + }); + assert.equal(completed[0]?.type, 'session_completed'); + + const interrupted = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + sessionId, + sessionStatus: 'SESSION_ABORTED', + }); + assert.equal(interrupted[0]?.type, 'session_interrupted'); +}); + +/** + * This test covers helper-3 notification flow: Claude permission callbacks should surface as tool_use_request. + */ +test('llmMessagesUnifier normalizes pre-unified tool_use_request payloads', () => { + const sessionId = 'permission-session-1'; + const messages = llmMessagesUnifier.normalizeUnknown('claude', sessionId, { + type: 'tool_use_request', + toolName: 'Read', + input: { filePath: 'notes.txt' }, + toolUseID: 'toolu_123', + title: 'Claude wants to read notes.txt', + }); + + assert.equal(messages[0]?.type, 'tool_use_request'); + assert.equal(messages[0]?.toolName, 'Read'); + assert.equal(messages[0]?.toolCallId, 'toolu_123'); +}); + +/** + * This test covers helper-3 runtime-event fallback behavior for non-JSON stdout/stderr stream messages. + */ +test('llmMessagesUnifier normalizes fallback session events with channel-aware error typing', () => { + const messages = llmMessagesUnifier.normalizeSessionEvents('gemini', 'runtime-session-1', [ + { + timestamp: '2026-04-06T12:00:00.000Z', + channel: 'stdout', + message: 'Process started', + }, + { + timestamp: '2026-04-06T12:00:01.000Z', + channel: 'error', + message: 'Process failed', + }, + ]); + + assert.equal(messages[0]?.type, 'assistant_message'); + assert.equal(messages[1]?.type, 'assistant_error_message'); +}); diff --git a/server/src/modules/llm/llm.routes.ts b/server/src/modules/llm/llm.routes.ts index 9242dd16..6a949e90 100644 --- a/server/src/modules/llm/llm.routes.ts +++ b/server/src/modules/llm/llm.routes.ts @@ -11,6 +11,7 @@ import { llmAssetsService } from '@/modules/llm/assets.service.js'; import type { McpScope, McpTransport, UpsertMcpServerInput } from '@/modules/llm/mcp.service.js'; import { llmMcpService } from '@/modules/llm/mcp.service.js'; import { llmSkillsService } from '@/modules/llm/skills.service.js'; +import { llmMessagesUnifier } from '@/modules/llm/messages-unifier.service.js'; import type { LLMProvider } from '@/shared/types/app.js'; import { logger } from '@/shared/utils/logger.js'; @@ -215,6 +216,25 @@ const parseProvider = (value: unknown): LLMProvider => { }); }; +/** + * Enriches provider session snapshots with normalized message types for frontend rendering. + */ +const formatSessionSnapshot = ( + provider: LLMProvider, + snapshot: { + sessionId: string; + events: Array<{ + timestamp: string; + channel: 'sdk' | 'stdout' | 'stderr' | 'json' | 'system' | 'error'; + message?: string; + data?: unknown; + }>; + }, +) => ({ + ...snapshot, + messages: llmMessagesUnifier.normalizeSessionEvents(provider, snapshot.sessionId, snapshot.events), +}); + router.get( '/providers', asyncHandler(async (_req: Request, res: Response) => { @@ -235,7 +255,7 @@ router.get( '/providers/:provider/sessions', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); - const sessions = llmService.listSessions(provider); + const sessions = llmService.listSessions(provider).map((session) => formatSessionSnapshot(provider, session)); res.json(createApiSuccessResponse({ provider, sessions })); }), ); @@ -253,7 +273,7 @@ router.get( }); } - res.json(createApiSuccessResponse({ provider, session })); + res.json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, session) })); }), ); @@ -265,17 +285,19 @@ router.post( const waitForCompletion = parseWaitForCompletion(req); if (!waitForCompletion) { + const formattedSnapshot = formatSessionSnapshot(provider, snapshot); res.status(202).json( createApiSuccessResponse({ provider, - session: snapshot, + session: formattedSnapshot, }), ); return; } const completedSnapshot = await llmService.waitForSession(provider, snapshot.sessionId); - res.json(createApiSuccessResponse({ provider, session: completedSnapshot ?? snapshot })); + const finalSnapshot = completedSnapshot ?? snapshot; + res.json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, finalSnapshot) })); }), ); @@ -289,12 +311,13 @@ router.post( const waitForCompletion = parseWaitForCompletion(req); if (!waitForCompletion) { - res.status(202).json(createApiSuccessResponse({ provider, session: snapshot })); + res.status(202).json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, snapshot) })); return; } const completedSnapshot = await llmService.waitForSession(provider, sessionId); - res.json(createApiSuccessResponse({ provider, session: completedSnapshot ?? snapshot })); + const finalSnapshot = completedSnapshot ?? snapshot; + res.json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, finalSnapshot) })); }), ); @@ -532,6 +555,19 @@ router.get( }), ); +router.get( + '/sessions/:sessionId/messages', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const history = await llmSessionsService.getSessionHistory(sessionId); + res.json(createApiSuccessResponse({ + sessionId, + provider: history.provider, + messages: history.messages, + })); + }), +); + router.get( '/sessions/:sessionId/history', asyncHandler(async (req: Request, res: Response) => { diff --git a/server/src/modules/llm/messages-unifier.service.ts b/server/src/modules/llm/messages-unifier.service.ts new file mode 100644 index 00000000..324202dd --- /dev/null +++ b/server/src/modules/llm/messages-unifier.service.ts @@ -0,0 +1,908 @@ +import type { ProviderSessionEvent } from '@/modules/llm/providers/provider.interface.js'; +import type { LLMProvider } from '@/shared/types/app.js'; + +export type UnifiedMessageType = + | 'user_message' + | 'thinking_message' + | 'assistant_message' + | 'assistant_error_message' + | 'tool_use_request' + | 'tool_call_success' + | 'tool_call_error' + | 'todo_task_list' + | 'session_started' + | 'session_completed' + | 'session_interrupted'; + +export type UnifiedSessionStatus = 'STARTED' | 'COMPLETED' | 'SESSION_ABORTED'; + +export type UnifiedChatMessage = { + timestamp: string; + provider: LLMProvider; + sessionId: string; + type: UnifiedMessageType; + text?: string; + images?: string[]; + toolName?: string; + toolCallId?: string; + status?: 'success' | 'error'; + has_progress_indicator?: boolean; + sessionStatus?: UnifiedSessionStatus; + data?: unknown; + raw?: unknown; +}; + +type MessageContext = { + provider: LLMProvider; + sessionId: string; + timestamp?: string; +}; + +/** + * Unifies provider-specific history/event payloads into one frontend-safe message contract. + */ +export const llmMessagesUnifier = { + /** + * Converts in-memory provider session events to unified chat messages. + */ + normalizeSessionEvents( + provider: LLMProvider, + sessionId: string, + events: ProviderSessionEvent[], + ): UnifiedChatMessage[] { + const messages: UnifiedChatMessage[] = []; + for (const event of events) { + const normalized = this.normalizeUnknown(provider, sessionId, event.data ?? event.message ?? event, event.timestamp); + if (normalized.length === 0 && event.message) { + messages.push(createMessage({ + provider, + sessionId, + timestamp: event.timestamp, + type: event.channel === 'error' ? 'assistant_error_message' : 'assistant_message', + text: event.message, + raw: event, + })); + continue; + } + + messages.push(...normalized); + } + + return messages; + }, + + /** + * Converts DB history payload entries to unified chat messages. + */ + normalizeHistoryEntries( + provider: LLMProvider, + sessionId: string, + entries: unknown[], + ): UnifiedChatMessage[] { + const messages: UnifiedChatMessage[] = []; + for (const entry of entries) { + messages.push(...this.normalizeUnknown(provider, sessionId, entry)); + } + + return messages; + }, + + /** + * Converts one raw provider payload to zero-or-more normalized messages. + */ + normalizeUnknown( + provider: LLMProvider, + sessionId: string, + raw: unknown, + timestamp?: string, + ): UnifiedChatMessage[] { + const context: MessageContext = { provider, sessionId, timestamp }; + if (!raw || typeof raw !== 'object') { + return []; + } + + const preUnified = normalizePreUnifiedPayload(raw as Record, context); + if (preUnified) { + return preUnified; + } + + if (provider === 'claude') { + return normalizeClaudePayload(raw as Record, context); + } + + if (provider === 'codex') { + return normalizeCodexPayload(raw as Record, context); + } + + if (provider === 'gemini') { + return normalizeGeminiPayload(raw as Record, context); + } + + return normalizeCursorPayload(raw as Record, context); + }, +}; + +/** + * Maps already-unified custom payloads (for example permission callbacks) without provider parsing. + */ +function normalizePreUnifiedPayload( + raw: Record, + context: MessageContext, +): UnifiedChatMessage[] | null { + const type = readString(raw.type); + if (!type) { + return null; + } + + if ( + type !== 'user_message' && + type !== 'thinking_message' && + type !== 'assistant_message' && + type !== 'assistant_error_message' && + type !== 'tool_use_request' && + type !== 'tool_call_success' && + type !== 'tool_call_error' && + type !== 'todo_task_list' && + type !== 'session_started' && + type !== 'session_completed' && + type !== 'session_interrupted' + ) { + return null; + } + + const statusValue = readString(raw.status); + const status = + statusValue === 'success' || statusValue === 'error' + ? statusValue + : undefined; + const sessionStatus = readString(raw.sessionStatus); + const normalizedSessionStatus = + sessionStatus === 'STARTED' || sessionStatus === 'COMPLETED' || sessionStatus === 'SESSION_ABORTED' + ? sessionStatus + : undefined; + const hasProgressIndicator = + readBoolean(raw.has_progress_indicator) ?? readBoolean(raw.hasProgressIndicator); + + return [ + createMessage({ + ...context, + timestamp: readString(raw.timestamp) ?? context.timestamp, + type, + text: readString(raw.text) ?? readString(raw.message), + images: readStringArray(raw.images), + toolName: readString(raw.toolName) ?? readString(raw.name), + toolCallId: readString(raw.toolCallId) ?? readString(raw.toolUseID) ?? readString(raw.call_id), + status, + has_progress_indicator: hasProgressIndicator, + sessionStatus: normalizedSessionStatus, + data: raw.data ?? raw.input ?? raw.payload, + raw, + }), + ]; +} + +/** + * Normalizes Claude payloads from both SDK stream and disk history. + */ +function normalizeClaudePayload( + raw: Record, + context: MessageContext, +): UnifiedChatMessage[] { + const sessionStatusMessage = normalizeSessionStatus(raw, context); + if (sessionStatusMessage) { + return [sessionStatusMessage]; + } + + const type = readString(raw.type); + const timestamp = readString(raw.timestamp) ?? context.timestamp; + + if (type === 'assistant') { + const messages: UnifiedChatMessage[] = []; + if (readString(raw.error)) { + messages.push(createMessage({ + ...context, + timestamp, + type: 'assistant_error_message', + text: readString(raw.error), + raw, + })); + } + + const messageRecord = readRecord(raw.message); + const contentBlocks = readArray(messageRecord?.content); + for (const contentBlock of contentBlocks) { + const block = readRecord(contentBlock); + if (!block) { + continue; + } + + const blockType = readString(block.type); + if (blockType === 'thinking') { + const thinkingText = readString(block.thinking) ?? 'Thinking'; + messages.push(createMessage({ + ...context, + timestamp, + type: 'thinking_message', + text: thinkingText.length ? thinkingText : 'Thinking', + raw: block, + })); + continue; + } + + if (blockType === 'text') { + const text = readString(block.text); + if (text) { + messages.push(createMessage({ + ...context, + timestamp, + type: 'assistant_message', + text, + raw: block, + })); + } + continue; + } + + if (blockType === 'tool_use') { + const toolName = readString(block.name); + const toolInput = readRecord(block.input) ?? block.input; + + if (toolName === 'TaskCreate' || toolName === 'TaskUpdate') { + messages.push(createMessage({ + ...context, + timestamp, + type: 'todo_task_list', + toolName, + has_progress_indicator: true, + data: toolInput, + raw: block, + })); + continue; + } + + messages.push(createMessage({ + ...context, + timestamp, + type: 'tool_use_request', + toolName, + toolCallId: readString(block.id), + data: toolInput, + raw: block, + })); + } + } + + return messages; + } + + if (type === 'user') { + // Tool results are emitted as user messages in Claude JSONL and should be treated as assistant tool results. + if (raw.toolUseResult !== undefined) { + const toolUseResult = readRecord(raw.toolUseResult) ?? raw.toolUseResult; + const successValue = readBoolean((toolUseResult as Record)?.success); + const status: 'success' | 'error' = successValue === false ? 'error' : 'success'; + + return [ + createMessage({ + ...context, + timestamp, + type: status === 'success' ? 'tool_call_success' : 'tool_call_error', + status, + data: toolUseResult, + raw, + }), + ]; + } + + const messageRecord = readRecord(raw.message); + const content = readArray(messageRecord?.content); + const textParts: string[] = []; + const images: string[] = []; + for (const contentBlock of content) { + const block = readRecord(contentBlock); + if (!block) { + continue; + } + + if (readString(block.type) === 'text') { + const text = readString(block.text); + if (text) { + textParts.push(text); + } + } + + if (readString(block.type) === 'image') { + const source = readRecord(block.source); + const data = readString(source?.data); + if (data) { + images.push(data); + } + } + } + + if (!textParts.length && !images.length) { + return []; + } + + return [ + createMessage({ + ...context, + timestamp, + type: 'user_message', + text: textParts.join('\n'), + images: images.length ? images : undefined, + raw, + }), + ]; + } + + return []; +} + +/** + * Normalizes Codex payloads from SDK stream/history JSONL. + */ +function normalizeCodexPayload( + raw: Record, + context: MessageContext, +): UnifiedChatMessage[] { + const sessionStatusMessage = normalizeSessionStatus(raw, context); + if (sessionStatusMessage) { + return [sessionStatusMessage]; + } + + const timestamp = readString(raw.timestamp) ?? context.timestamp; + const type = readString(raw.type); + + if (type === 'error') { + return [ + createMessage({ + ...context, + timestamp, + type: 'assistant_error_message', + text: readString(raw.message) ?? 'Codex stream error', + raw, + }), + ]; + } + + if (type === 'event_msg') { + const payload = readRecord(raw.payload); + const payloadType = readString(payload?.type); + if (payloadType === 'user_message') { + const text = readString(payload?.message); + const localImages = readStringArray(payload?.local_images); + const remoteImages = readStringArray(payload?.images); + return [ + createMessage({ + ...context, + timestamp, + type: 'user_message', + text, + images: [...localImages, ...remoteImages], + raw, + }), + ]; + } + + if (payloadType === 'exec_command_end') { + const status = readString(payload?.status) === 'failed' ? 'error' : 'success'; + return [ + createMessage({ + ...context, + timestamp, + type: status === 'success' ? 'tool_call_success' : 'tool_call_error', + status, + toolName: 'shell_command', + toolCallId: readString(payload?.call_id), + data: payload, + raw, + }), + ]; + } + } + + if (type === 'response_item') { + const payload = readRecord(raw.payload); + const payloadType = readString(payload?.type); + if (payloadType === 'reasoning') { + const summary = readArray(payload?.summary); + const summaryText = summary + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + const record = readRecord(entry); + return readString(record?.text) ?? readString(record?.summary) ?? ''; + }) + .filter((entry) => entry.length > 0) + .join('\n'); + + return [ + createMessage({ + ...context, + timestamp, + type: 'thinking_message', + text: summaryText || 'Reasoning', + data: payload, + raw, + }), + ]; + } + + if (payloadType === 'function_call') { + const toolName = readString(payload?.name); + const toolCallId = readString(payload?.call_id); + const argsText = readString(payload?.arguments); + const parsedArgs = parseJsonSafely(argsText) ?? argsText; + + if (toolName === 'update_plan') { + return [ + createMessage({ + ...context, + timestamp, + type: 'todo_task_list', + toolName, + toolCallId, + has_progress_indicator: true, + data: parsedArgs, + raw, + }), + ]; + } + + return [ + createMessage({ + ...context, + timestamp, + type: 'tool_use_request', + toolName, + toolCallId, + data: parsedArgs, + raw, + }), + ]; + } + + if (payloadType === 'function_call_output') { + const output = readString(payload?.output) ?? ''; + const status: 'success' | 'error' = /exit code:\s*0/i.test(output) ? 'success' : 'error'; + return [ + createMessage({ + ...context, + timestamp, + type: status === 'success' ? 'tool_call_success' : 'tool_call_error', + status, + toolCallId: readString(payload?.call_id), + text: output, + data: payload, + raw, + }), + ]; + } + + if (payloadType === 'message') { + const role = readString(payload?.role); + const content = readArray(payload?.content); + const text = content + .map((entry) => { + const block = readRecord(entry); + return readString(block?.text) ?? ''; + }) + .filter(Boolean) + .join('\n'); + + if (role === 'user' && text.includes('')) { + return [ + createMessage({ + ...context, + timestamp, + type: 'session_interrupted', + sessionStatus: 'SESSION_ABORTED', + text, + raw, + }), + ]; + } + + return [ + createMessage({ + ...context, + timestamp, + type: role === 'user' ? 'user_message' : 'assistant_message', + text, + data: payload, + raw, + }), + ]; + } + + if (payloadType === 'error') { + return [ + createMessage({ + ...context, + timestamp, + type: 'assistant_error_message', + text: readString(payload?.message) ?? 'Codex error', + data: payload, + raw, + }), + ]; + } + } + + // SDK thread item-based events + const item = readRecord(raw.item); + if (!item) { + return []; + } + + const itemType = readString(item.type); + if (itemType === 'reasoning') { + const text = readString(item.summary) ?? 'Reasoning'; + return [createMessage({ ...context, timestamp, type: 'thinking_message', text, raw })]; + } + + if (itemType === 'error') { + return [ + createMessage({ + ...context, + timestamp, + type: 'assistant_error_message', + text: readString(item.message) ?? 'Codex item error', + raw, + }), + ]; + } + + if (itemType === 'todo_list') { + return [ + createMessage({ + ...context, + timestamp, + type: 'todo_task_list', + has_progress_indicator: true, + data: item, + raw, + }), + ]; + } + + if (itemType === 'agent_message') { + return [ + createMessage({ + ...context, + timestamp, + type: 'assistant_message', + text: readString(item.message) ?? '', + raw, + }), + ]; + } + + return []; +} + +/** + * Normalizes Gemini payloads from JSON history files and runtime stream chunks. + */ +function normalizeGeminiPayload( + raw: Record, + context: MessageContext, +): UnifiedChatMessage[] { + const sessionStatusMessage = normalizeSessionStatus(raw, context); + if (sessionStatusMessage) { + return [sessionStatusMessage]; + } + + if (Array.isArray(raw.messages)) { + const messages: UnifiedChatMessage[] = []; + for (const message of raw.messages) { + const parsedMessage = readRecord(message); + if (!parsedMessage) { + continue; + } + + messages.push(...normalizeGeminiPayload(parsedMessage, context)); + } + return messages; + } + + const timestamp = readString(raw.timestamp) ?? context.timestamp; + const type = readString(raw.type); + const unified: UnifiedChatMessage[] = []; + + if (type === 'user') { + const text = readArray(raw.content) + .map((entry) => readString(readRecord(entry)?.text) ?? '') + .filter(Boolean) + .join('\n'); + unified.push(createMessage({ + ...context, + timestamp, + type: 'user_message', + text, + raw, + })); + } + + if (type === 'gemini') { + const assistantText = readString(raw.content) ?? ''; + if (assistantText.length) { + unified.push(createMessage({ + ...context, + timestamp, + type: 'assistant_message', + text: assistantText, + raw, + })); + } + } + + const thoughts = readArray(raw.thoughts); + for (const thought of thoughts) { + const thoughtRecord = readRecord(thought); + if (!thoughtRecord) { + continue; + } + const text = readString(thoughtRecord.description) ?? readString(thoughtRecord.subject) ?? 'Thinking'; + unified.push(createMessage({ + ...context, + timestamp: readString(thoughtRecord.timestamp) ?? timestamp, + type: 'thinking_message', + text, + raw: thoughtRecord, + })); + } + + const toolCalls = readArray(raw.toolCalls); + for (const toolCall of toolCalls) { + const toolRecord = readRecord(toolCall); + if (!toolRecord) { + continue; + } + + const status = readString(toolRecord.status) === 'error' ? 'error' : 'success'; + unified.push(createMessage({ + ...context, + timestamp: readString(toolRecord.timestamp) ?? timestamp, + type: status === 'success' ? 'tool_call_success' : 'tool_call_error', + status, + toolName: readString(toolRecord.displayName) ?? readString(toolRecord.name), + toolCallId: readString(toolRecord.id), + data: { + args: toolRecord.args, + result: toolRecord.result, + resultDisplay: toolRecord.resultDisplay, + }, + raw: toolRecord, + })); + } + + return unified; +} + +/** + * Normalizes Cursor payloads from JSONL entries. + */ +function normalizeCursorPayload( + raw: Record, + context: MessageContext, +): UnifiedChatMessage[] { + const sessionStatusMessage = normalizeSessionStatus(raw, context); + if (sessionStatusMessage) { + return [sessionStatusMessage]; + } + + const role = readString(raw.role); + const timestamp = readString(raw.timestamp) ?? context.timestamp; + const message = readRecord(raw.message); + const content = readArray(message?.content); + const normalized: UnifiedChatMessage[] = []; + + if (role === 'user') { + const text = content + .map((entry) => readString(readRecord(entry)?.text) ?? '') + .filter(Boolean) + .join('\n'); + const strippedText = stripCursorUserQueryTags(text); + if (!strippedText) { + return []; + } + + return [ + createMessage({ + ...context, + timestamp, + type: 'user_message', + text: strippedText, + raw, + }), + ]; + } + + if (role !== 'assistant') { + return []; + } + + for (const entry of content) { + const block = readRecord(entry); + if (!block) { + continue; + } + + const blockType = readString(block.type); + if (blockType === 'text') { + const text = readString(block.text); + if (!text) { + continue; + } + + normalized.push(createMessage({ + ...context, + timestamp, + type: 'assistant_message', + text, + raw: block, + })); + continue; + } + + if (blockType === 'tool_use') { + const toolName = readString(block.name); + const input = block.input; + if (toolName === 'CreatePlan') { + normalized.push(createMessage({ + ...context, + timestamp, + type: 'todo_task_list', + toolName, + has_progress_indicator: false, + data: input, + raw: block, + })); + continue; + } + + normalized.push(createMessage({ + ...context, + timestamp, + type: 'tool_call_success', + status: 'success', + toolName, + data: input, + raw: block, + })); + } + } + + return normalized; +} + +/** + * Maps shared session status payloads into unified session event message types. + */ +function normalizeSessionStatus( + raw: Record, + context: MessageContext, +): UnifiedChatMessage | null { + const sessionStatus = readString(raw.sessionStatus); + if (!sessionStatus) { + return null; + } + + if (sessionStatus === 'STARTED') { + return createMessage({ + ...context, + timestamp: readString(raw.timestamp) ?? context.timestamp, + type: 'session_started', + sessionStatus: 'STARTED', + raw, + }); + } + + if (sessionStatus === 'COMPLETED') { + return createMessage({ + ...context, + timestamp: readString(raw.timestamp) ?? context.timestamp, + type: 'session_completed', + sessionStatus: 'COMPLETED', + raw, + }); + } + + if (sessionStatus === 'SESSION_ABORTED') { + return createMessage({ + ...context, + timestamp: readString(raw.timestamp) ?? context.timestamp, + type: 'session_interrupted', + sessionStatus: 'SESSION_ABORTED', + raw, + }); + } + + return null; +} + +/** + * Strips cursor `...` wrappers from user messages. + */ +function stripCursorUserQueryTags(value: string): string { + return value + .replace(//gi, '') + .replace(/<\/user_query>/gi, '') + .trim(); +} + +/** + * Creates one normalized message with defaults. + */ +function createMessage(input: Omit & { timestamp?: string }): UnifiedChatMessage { + return { + ...input, + timestamp: input.timestamp ?? new Date().toISOString(), + }; +} + +/** + * Safe object record cast. + */ +function readRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +/** + * Safe array cast. + */ +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +/** + * Safe string parser. + */ +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +/** + * Safe boolean parser. + */ +function readBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +/** + * Safe string-array parser. + */ +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +/** + * Best-effort JSON parse helper. + */ +function parseJsonSafely(value?: string): unknown { + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch { + return null; + } +} diff --git a/server/src/modules/llm/providers/abstract.provider.ts b/server/src/modules/llm/providers/abstract.provider.ts index 5ed6d171..4b8c9d2b 100644 --- a/server/src/modules/llm/providers/abstract.provider.ts +++ b/server/src/modules/llm/providers/abstract.provider.ts @@ -93,6 +93,10 @@ export abstract class AbstractProvider implements IProvider { timestamp: new Date().toISOString(), channel: 'system', message: 'Session stop requested.', + data: { + sessionId, + sessionStatus: 'SESSION_ABORTED', + }, }); } @@ -220,6 +224,16 @@ export abstract class AbstractProvider implements IProvider { thinkingMode: input.thinkingMode, }); + this.appendEvent(session, { + timestamp: session.startedAt, + channel: 'system', + message: 'Session started.', + data: { + sessionId, + sessionStatus: 'STARTED', + }, + }); + return session; } diff --git a/server/src/modules/llm/providers/base-cli.provider.ts b/server/src/modules/llm/providers/base-cli.provider.ts index 85f94ec5..db31ce8a 100644 --- a/server/src/modules/llm/providers/base-cli.provider.ts +++ b/server/src/modules/llm/providers/base-cli.provider.ts @@ -240,6 +240,15 @@ export abstract class BaseCliProvider extends AbstractProvider { if (code === 0) { this.updateSessionStatus(session, 'completed'); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: 'Session completed.', + data: { + sessionId: session.sessionId, + sessionStatus: 'COMPLETED', + }, + }); return; } diff --git a/server/src/modules/llm/providers/base-sdk.provider.ts b/server/src/modules/llm/providers/base-sdk.provider.ts index 18a87097..c5f2b2f8 100644 --- a/server/src/modules/llm/providers/base-sdk.provider.ts +++ b/server/src/modules/llm/providers/base-sdk.provider.ts @@ -13,6 +13,7 @@ import type { LLMProvider } from '@/shared/types/app.js'; type CreateSdkExecutionInput = StartSessionInput & { sessionId: string; isResume: boolean; + emitEvent?: (event: ProviderSessionEvent) => void; }; type SdkExecution = { @@ -86,6 +87,9 @@ export abstract class BaseSdkProvider extends AbstractProvider { ...input, model: effectiveModel, thinkingMode: effectiveThinking, + emitEvent: (event) => { + this.appendEvent(session, event); + }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to start SDK session'; @@ -123,6 +127,15 @@ export abstract class BaseSdkProvider extends AbstractProvider { if (session.status === 'running') { this.updateSessionStatus(session, 'completed'); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: 'Session completed.', + data: { + sessionId: session.sessionId, + sessionStatus: 'COMPLETED', + }, + }); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown SDK execution failure'; diff --git a/server/src/modules/llm/providers/claude.provider.ts b/server/src/modules/llm/providers/claude.provider.ts index af9155c3..82fc5f13 100644 --- a/server/src/modules/llm/providers/claude.provider.ts +++ b/server/src/modules/llm/providers/claude.provider.ts @@ -18,6 +18,7 @@ import type { type ClaudeExecutionInput = StartSessionInput & { sessionId: string; isResume: boolean; + emitEvent?: (event: ProviderSessionEvent) => void; }; const CLAUDE_THINKING_LEVELS = new Set(['low', 'medium', 'high', 'max']); @@ -52,6 +53,18 @@ type ClaudeUserPromptMessage = { timestamp: string; }; +/** + * Safely reads one optional string value from unknown data. + */ +const readString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length ? normalized : undefined; +}; + /** * Claude SDK provider implementation. */ @@ -97,7 +110,7 @@ export class ClaudeProvider extends BaseSdkProvider { cwd: input.workspacePath, model: input.model, effort: this.resolveClaudeEffort(input.thinkingMode), - canUseTool: this.resolvePermissionHandler(input.runtimePermissionMode), + canUseTool: this.resolvePermissionHandler(input.runtimePermissionMode, input.emitEvent), }; if (input.isResume) { @@ -232,20 +245,59 @@ export class ClaudeProvider extends BaseSdkProvider { /** * Builds a runtime permission callback when explicit allow/deny is requested. */ - private resolvePermissionHandler(mode?: RuntimePermissionMode): CanUseTool | undefined { + private resolvePermissionHandler( + mode?: RuntimePermissionMode, + emitEvent?: (event: ProviderSessionEvent) => void, + ): CanUseTool | undefined { if (!mode || mode === 'ask') { return undefined; } if (mode === 'allow') { - return async () => ({ behavior: 'allow' }); + return async (toolName, input, options) => { + const optionsRecord = options as Record; + emitEvent?.({ + timestamp: new Date().toISOString(), + channel: 'system', + message: `Tool permission requested for "${toolName}".`, + data: { + type: 'tool_use_request', + toolName, + input, + toolUseID: options.toolUseID, + title: readString(optionsRecord.title), + displayName: readString(optionsRecord.displayName), + description: readString(optionsRecord.description), + blockedPath: options.blockedPath, + }, + }); + return { behavior: 'allow' }; + }; } - return async () => ({ - behavior: 'deny', - message: 'Permission denied by runtime permission mode.', - interrupt: false, - }); + return async (toolName, input, options) => { + const optionsRecord = options as Record; + emitEvent?.({ + timestamp: new Date().toISOString(), + channel: 'system', + message: `Tool permission denied for "${toolName}".`, + data: { + type: 'tool_use_request', + toolName, + input, + toolUseID: options.toolUseID, + title: readString(optionsRecord.title), + displayName: readString(optionsRecord.displayName), + description: readString(optionsRecord.description), + blockedPath: options.blockedPath, + }, + }); + return { + behavior: 'deny', + message: 'Permission denied by runtime permission mode.', + interrupt: false, + }; + }; } /** diff --git a/server/src/modules/llm/sessions.service.ts b/server/src/modules/llm/sessions.service.ts index 7cc4a4ee..6848bf30 100644 --- a/server/src/modules/llm/sessions.service.ts +++ b/server/src/modules/llm/sessions.service.ts @@ -6,6 +6,7 @@ import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; import type { LLMProvider } from '@/shared/types/app.js'; import { AppError } from '@/shared/utils/app-error.js'; import { sessionIndexers } from '@/modules/llm/session-indexers/index.js'; +import { llmMessagesUnifier, type UnifiedChatMessage } from '@/modules/llm/messages-unifier.service.js'; type SyncResult = { processedByProvider: Record; @@ -19,6 +20,7 @@ type SessionHistoryPayload = { filePath: string; fileType: 'jsonl' | 'json'; entries: unknown[]; + messages: UnifiedChatMessage[]; }; const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/; @@ -223,6 +225,7 @@ export const llmSessionsService = { const fileContent = await readFile(filePath, 'utf8'); const extension = path.extname(filePath).toLowerCase(); const isGeminiJson = session.provider === 'gemini' || extension === '.json'; + const entries = isGeminiJson ? parseJson(fileContent) : parseJsonl(fileContent); return { sessionId: session.session_id, @@ -230,7 +233,12 @@ export const llmSessionsService = { workspacePath: session.workspace_path, filePath, fileType: isGeminiJson ? 'json' : 'jsonl', - entries: isGeminiJson ? parseJson(fileContent) : parseJsonl(fileContent), + entries, + messages: llmMessagesUnifier.normalizeHistoryEntries( + session.provider as LLMProvider, + session.session_id, + entries, + ), }; }, };