diff --git a/apps/desktop/src/components/codegen/AttachmentList.svelte b/apps/desktop/src/components/codegen/AttachmentList.svelte index 624f8bbe5a..0e26a9106f 100644 --- a/apps/desktop/src/components/codegen/AttachmentList.svelte +++ b/apps/desktop/src/components/codegen/AttachmentList.svelte @@ -17,14 +17,16 @@ } function getTooltipText(attachment: PromptAttachment): string { - if (attachment.type === 'commit') { - return `${attachment.commitId}`; - } else if (attachment.type === 'file') { - const commitInfo = attachment.commitId ? ` (from commit ${attachment.commitId})` : ''; - return `${attachment.path}${commitInfo}`; - } else if (attachment.type === 'lines') { - const commitInfo = attachment.commitId ? ` (from commit ${attachment.commitId})` : ''; - return `Lines ${attachment.start}-${attachment.end}${attachment.path}${commitInfo}`; + if (`commit` in attachment) { + return `${attachment.commit.commitId}`; + } else if ('file' in attachment) { + const { commitId, path } = attachment.file; + const commitInfo = commitId ? ` (from commit ${commitId})` : ''; + return `${path}${commitInfo}`; + } else if ('lines' in attachment) { + const { commitId, path, start, end } = attachment.lines; + const commitInfo = commitId ? ` (from commit ${commitId})` : ''; + return `Lines ${start}-${end}${path}${commitInfo}`; } return ''; } @@ -36,36 +38,37 @@
- {#if attachment.type === 'commit'} + {#if 'commit' in attachment} - #{attachment.commitId.slice(0, 6)} + #{attachment.commit.commitId.slice(0, 7)} {/if} - {#if attachment.type === 'file'} - + {#if `file` in attachment} + {@const { path, commitId } = attachment.file} + - {splitFilePath(attachment.path).filename} + {splitFilePath(path).filename} - {#if attachment.commitId} + {#if commitId} - + - #{attachment.commitId.slice(0, 6)} + #{commitId.slice(0, 7)} {/if} {/if} - {#if attachment.type === 'lines'} - {@const { start, end } = attachment} - + {#if `lines` in attachment} + {@const { commitId, path, start, end } = attachment.lines} + - {splitFilePath(attachment.path).filename} + {splitFilePath(path).filename} @@ -73,11 +76,11 @@ {start}:{end} - {#if attachment.commitId} + {#if commitId} - + - #{attachment.commitId.slice(0, 6)} + #{commitId.slice(0, 7)} {/if} diff --git a/apps/desktop/src/lib/codegen/attachmentService.svelte.ts b/apps/desktop/src/lib/codegen/attachmentService.svelte.ts index 2cd2f5de49..760d5733f2 100644 --- a/apps/desktop/src/lib/codegen/attachmentService.svelte.ts +++ b/apps/desktop/src/lib/codegen/attachmentService.svelte.ts @@ -77,10 +77,9 @@ export class AttachmentService { } } -export const promptAttachmentAdapter = createEntityAdapter< - { branchName: string; attachments: PromptAttachment[] }, - string ->({ +export type PromptAttachmentRecord = { branchName: string; attachments: PromptAttachment[] }; + +export const promptAttachmentAdapter = createEntityAdapter({ selectId: (a) => a.branchName }); diff --git a/apps/desktop/src/lib/codegen/dropzone.ts b/apps/desktop/src/lib/codegen/dropzone.ts index 5b14ab67cd..6ed727b647 100644 --- a/apps/desktop/src/lib/codegen/dropzone.ts +++ b/apps/desktop/src/lib/codegen/dropzone.ts @@ -57,7 +57,7 @@ export class CodegenCommitDropHandler implements DropzoneHandler { } ondrop(data: CommitDropData): void { - this.add([{ type: 'commit', branchName: this.branchName, commitId: data.commit.id }]); + this.add([{ commit: { commitId: data.commit.id } }]); } } @@ -79,10 +79,11 @@ export class CodegenFileDropHandler implements DropzoneHandler { const changes = await data.treeChanges(); const commitId = data.selectionId.type === 'commit' ? data.selectionId.commitId : undefined; const attachments: PromptAttachment[] = changes.map((change) => ({ - type: 'file', branchName: this.branchName, - path: change.path, - commitId + file: { + path: change.path, + commitId + } })); this.add(attachments); } @@ -105,12 +106,12 @@ export class CodegenHunkDropHandler implements DropzoneHandler { ondrop(data: HunkDropDataV3): void { this.add([ { - type: 'lines', - branchName: this.branchName, - path: data.change.path, - start: data.hunk.newStart, - end: data.hunk.newStart + data.hunk.newLines - 1, - commitId: data.commitId + lines: { + path: data.change.path, + start: data.hunk.newStart, + end: data.hunk.newStart + data.hunk.newLines - 1, + commitId: data.commitId + } } ]); } diff --git a/apps/desktop/src/lib/codegen/messages.ts b/apps/desktop/src/lib/codegen/messages.ts index 303d3f9f84..b98c766ce2 100644 --- a/apps/desktop/src/lib/codegen/messages.ts +++ b/apps/desktop/src/lib/codegen/messages.ts @@ -69,19 +69,19 @@ export function formatMessages( let lastAssistantMessage: Message | undefined = undefined; for (const [_idx, event] of events.entries()) { - if (event.content.type === 'userInput') { + if ('user' in event.payload) { wrapUpAgentSide(); out.push({ type: 'user', - message: event.content.subject.message, - attachments: event.content.subject.attachments + message: event.payload.user.message, + attachments: event.payload.user.attachments }); lastAssistantMessage = undefined; - } else if (event.content.type === 'claudeOutput') { - const subject = event.content.subject; + } else if ('claude' in event.payload) { + const payload = event.payload.claude.data; // We've either triggered a tool call, or sent a message - if (subject.type === 'assistant') { - const message = subject.message; + if (payload.type === 'assistant') { + const message = payload.message; if (message.content[0]!.type === 'text') { if (message.content[0]!.text === loginRequiredMessage) { continue; @@ -123,8 +123,8 @@ export function formatMessages( } toolCalls[toolCall.id] = toolCall; } - } else if (subject.type === 'user') { - const content = subject.message.content; + } else if (payload.type === 'user') { + const content = payload.message.content; if (Array.isArray(content) && content[0]!.type === 'tool_result') { const result = content[0]!; const foundToolCall = toolCalls[result.tool_use_id]; @@ -140,64 +140,59 @@ export function formatMessages( } } } - } else if (event.content.type === 'gitButlerMessage') { - const subject = event.content.subject; + } else if ('system' in event.payload) { + const message = event.payload.system; if ( - subject.type === 'claudeExit' || - subject.type === 'userAbort' || - subject.type === 'unhandledException' || - subject.type === 'compactStart' || - subject.type === 'compactFinished' + message.type === 'claudeExit' || + message.type === 'userAbort' || + message.type === 'unhandledException' || + message.type === 'compactStart' || + message.type === 'compactFinished' ) { wrapUpAgentSide(); } - if (subject.type === 'claudeExit' && subject.subject.code !== 0) { + if (message.type === 'claudeExit' && message.code !== 0) { if (previousEventLoginFailureQuery(events, event)) { - const message: Message = { + out.push({ type: 'claude', message: `Claude Code is currently not logged in.\n\n Please run \`claude\` in your terminal and complete the login flow in order to use the GitButler Claude Code integration.`, toolCalls: [], toolCallsPendingApproval: [] - }; - out.push(message); + }); } else { - const message: Message = { + out.push({ type: 'claude', - message: `Claude exited with non 0 error code \n\n\`\`\`\n${subject.subject.message}\n\`\`\``, + message: `Claude exited with non 0 error code \n\n\`\`\`\n${message.message}\n\`\`\``, toolCalls: [], toolCallsPendingApproval: [] - }; - out.push(message); + }); } } - if (subject.type === 'unhandledException') { - const message: Message = { + if (message.type === 'unhandledException') { + out.push({ type: 'claude', - message: `Encountered an unhandled exception when executing Claude.\nPlease verify your Claude Code installation location and try clearing the context. \n\n\`\`\`\n${subject.subject.message}\n\`\`\``, + message: `Encountered an unhandled exception when executing Claude.\nPlease verify your Claude Code installation location and try clearing the context. \n\n\`\`\`\n${message.message}\n\`\`\``, toolCalls: [], toolCallsPendingApproval: [] - }; - out.push(message); + }); } - if (subject.type === 'userAbort') { - const message: Message = { + if (message.type === 'userAbort') { + out.push({ type: 'claude', message: `I've stopped! What can I help you with next?`, toolCalls: [], toolCallsPendingApproval: [] - }; - out.push(message); + }); } - if (subject.type === 'compactFinished') { - const message: Message = { + if (message.type === 'compactFinished') { + out.push({ type: 'claude', subtype: 'compaction', - message: `Context compaction completed: ${subject.subject.summary}`, + message: `Context compaction completed: ${message.summary}`, toolCalls: [], toolCallsPendingApproval: [] - }; - out.push(message); + }); } } } @@ -231,10 +226,12 @@ function previousEventLoginFailureQuery(events: ClaudeMessage[], event: ClaudeMe const idx = events.findIndex((e) => e === event); if (idx <= 0) return false; const previous = events[idx - 1]!; - if (previous.content.type !== 'claudeOutput') return false; - if (previous.content.subject.type !== 'result') return false; - if (previous.content.subject.subtype !== 'success') return false; - if (previous.content.subject.result !== loginRequiredMessage) return false; + + if (!('claude' in previous.payload)) return false; + const content = previous.payload.claude.data; + if (content.type !== 'result') return false; + if (content.subtype !== 'success') return false; + if (content.result !== loginRequiredMessage) return false; return true; } @@ -328,10 +325,10 @@ export function usageStats(events: ClaudeMessage[]): { for (let i = events.length - 1; i >= 0; i--) { const event = events[i]!; - if (event.content.type !== 'claudeOutput') continue; - const message = event.content.subject; - if (message.type !== 'assistant') continue; - lastAssistantMessage = message; + if (!('claude' in event.payload)) continue; + const content = event.payload.claude.data; + if (content.type !== 'assistant') continue; + lastAssistantMessage = content; break; } @@ -352,14 +349,14 @@ export function usageStats(events: ClaudeMessage[]): { for (let i = events.length - 1; i >= 0; i--) { const event = events[i]!; - if (event.content.type !== 'claudeOutput') continue; - const message = event.content.subject; - if (message.type !== 'assistant') continue; - if (usedIds.has(message.message.id)) continue; - usedIds.add(message.message.id); - const modelPricing = findModelPricing(message.message.model); + if (!('claude' in event.payload)) continue; + const content = event.payload.claude.data; + if (content.type !== 'assistant') continue; + if (usedIds.has(content.message.id)) continue; + usedIds.add(content.message.id); + const modelPricing = findModelPricing(content.message.model); if (!modelPricing) continue; - const usage = message.message.usage; + const usage = content.message.usage; cost += (usage.input_tokens * modelPricing.input) / 1_000_000; cost += (usage.output_tokens * modelPricing.output) / 1_000_000; @@ -386,25 +383,22 @@ function findModelPricing(name: string) { export function currentStatus(events: ClaudeMessage[], isActive: boolean): ClaudeStatus { if (events.length === 0) return 'disabled'; const lastEvent = events.at(-1)!; - if (lastEvent.content.type === 'claudeOutput' && lastEvent.content.subject.type === 'result') { + if ('claude' in lastEvent.payload && lastEvent.payload.claude.data.type === 'result') { // Once we have the TODOs, if all the TODOs are completed, we can change // this to conditionally return 'enabled' or 'completed' return 'enabled'; } - if ( - lastEvent.content.type === 'gitButlerMessage' && - lastEvent.content.subject.type === 'compactStart' - ) { + if ('system' in lastEvent.payload && lastEvent.payload.system.type === 'compactStart') { return 'compacting'; } if ( - lastEvent.content.type === 'gitButlerMessage' && - (lastEvent.content.subject.type === 'userAbort' || - lastEvent.content.subject.type === 'claudeExit' || - lastEvent.content.subject.type === 'unhandledException' || - lastEvent.content.subject.type === 'compactFinished') + 'system' in lastEvent.payload && + (lastEvent.payload.system.type === 'userAbort' || + lastEvent.payload.system.type === 'claudeExit' || + lastEvent.payload.system.type === 'unhandledException' || + lastEvent.payload.system.type === 'compactFinished') ) { // Once we have the TODOs, if all the TODOs are completed, we can change // this to conditionally return 'enabled' or 'completed' @@ -429,11 +423,8 @@ export function isCompletedWithStatus(events: ClaudeMessage[], isActive: boolean // The last event after 'completed' is _usually_ "claudeExit", but not // always. If it is, we use it, or just assume success. const lastEvent = events.at(-1)!; - if ( - lastEvent.content.type === 'gitButlerMessage' && - lastEvent.content.subject.type === 'claudeExit' - ) { - return { type: 'completed', code: lastEvent.content.subject.subject.code }; + if ('system' in lastEvent.payload && lastEvent.payload.system.type === 'claudeExit') { + return { type: 'completed', code: lastEvent.payload.system.code }; } else { return { type: 'completed', code: 0 }; } @@ -454,8 +445,8 @@ export function thinkingOrCompactingStartedAt(events: ClaudeMessage[]): Date | u for (let i = events.length - 1; i >= 0; --i) { const e = events[i]!; if ( - e.content.type === 'userInput' || - (e.content.type === 'gitButlerMessage' && e.content.subject.type === 'compactStart') + 'user' in e.payload || + ('system' in e.payload && e.payload.system.type === 'compactStart') ) { event = e; break; @@ -484,11 +475,11 @@ export function lastInteractionTime(events: ClaudeMessage[]): Date | undefined { export function getTodos(events: ClaudeMessage[]): ClaudeTodo[] { let todos: ClaudeTodo[] | undefined; for (let i = events.length - 1; i >= 0; --i) { - const content = events[i]!.content; - if (content.type !== 'claudeOutput') continue; - const subject = content.subject; - if (subject.type !== 'assistant') continue; - const msgContent = subject.message.content[0]!; + const event = events[i]!; + if (!('claude' in event.payload)) continue; + const content = event.payload.claude.data; + if (content.type !== 'assistant') continue; + const msgContent = content.message.content[0]!; if (msgContent.type !== 'tool_use') continue; if (msgContent.name !== 'TodoWrite') continue; todos = (msgContent.input as { todos: ClaudeTodo[] }).todos; @@ -545,30 +536,30 @@ export function extractLastMessage(messages: ClaudeMessage[]): string | undefine const message = messages[i]; if (!message) continue; - const { content } = message; - if (content.type === 'claudeOutput') { - const output = content.subject; - if (output.type === 'assistant') { - const contentBlocks = output.message.content; + const { payload } = message; + if ('claude' in payload) { + const content = payload.claude.data; + if (content.type === 'assistant') { + const contentBlocks = content.message.content; const summary = contentBlockToString(contentBlocks.at(-1)); if (summary) return summary; - } else if (output.type === 'result') { - if (output.subtype === 'success') { - if (output.result) return output.result; + } else if (content.type === 'result') { + if (content.subtype === 'success') { + if (content.result) return content.result; } else { return 'an error has occurred'; } - } else if (output.type === 'user') { - const content = output.message.content; - if (typeof content === 'string') { - return content; + } else if (content.type === 'user') { + const messageContent = content.message.content; + if (typeof messageContent === 'string') { + return messageContent; } else { - const summary = contentBlockToString(content.at(-1)); + const summary = contentBlockToString(messageContent.at(-1)); if (summary) return summary; } } - } else if (content.type === 'userInput') { - return content.subject.message; + } else if ('user' in payload) { + return payload.user.message; } } } diff --git a/apps/desktop/src/lib/codegen/types.ts b/apps/desktop/src/lib/codegen/types.ts index 63ee241f58..6fec66a373 100644 --- a/apps/desktop/src/lib/codegen/types.ts +++ b/apps/desktop/src/lib/codegen/types.ts @@ -3,24 +3,10 @@ import type { Message, MessageParam, Usage } from '@anthropic-ai/sdk/resources/i /** * Represents a file attachment with full content (used in API input). */ -export type PromptAttachment = { branchName: string } & ( - | { - type: 'file'; - path: string; - commitId?: string; - } - | { - type: 'lines'; - path: string; - start: number; - end: number; - commitId?: string; - } - | { - type: 'commit'; - commitId: string; - } -); +export type PromptAttachment = + | { file: { path: string; commitId?: string } } + | { lines: { path: string; start: number; end: number; commitId?: string } } + | { commit: { commitId: string } }; /** * Result of checking Claude Code availability @@ -118,56 +104,57 @@ export type ClaudeMessage = { sessionId: string; /** The timestamp when the message was created. */ createdAt: string; - /** The content of the message, which can be either output from Claude or user input. */ - content: ClaudeMessageContent; + /** The payload of the message from different sources. */ + payload: MessagePayload; }; /** - * Represents the kind of content in a Claude message. + * The actual message payload from different sources. + * Uses external tagging for protobuf compatibility. */ -export type ClaudeMessageContent = - /** Came from Claude standard out stream */ - | { - type: 'claudeOutput'; - subject: ClaudeCodeMessage; - } - /** Inserted via GitButler (what the user typed) */ - | { - type: 'userInput'; - subject: { message: string; attachments?: PromptAttachment[] }; - } - | { - type: 'gitButlerMessage'; - subject: GitButlerMessage; - }; +export type MessagePayload = + /** Output from Claude Code CLI stdout stream */ + | { claude: ClaudeOutput } + /** Input provided by the user */ + | { user: UserInput } + /** System message from GitButler about the session */ + | { system: SystemMessage }; + +/** + * Raw output from Claude API + */ +export type ClaudeOutput = { + /** Raw JSON value from Claude API streaming output */ + data: ClaudeCodeMessage; +}; + +export type UserInput = { + message: string; + attachments?: PromptAttachment[]; +}; -export type GitButlerMessage = +/** + * System messages from GitButler about the Claude session state. + */ +export type SystemMessage = | { type: 'claudeExit'; - subject: { - code: number; - message: string; - }; + code: number; + message: string; } | { type: 'userAbort'; - subject: undefined; } | { type: 'unhandledException'; - subject: { - message: string; - }; + message: string; } | { type: 'compactStart'; - subject: undefined; } | { type: 'compactFinished'; - subject: { - summary: string; - }; + summary: string; }; /** diff --git a/crates/but-claude/src/bridge.rs b/crates/but-claude/src/bridge.rs index c51c72094a..0ed8e1fe09 100644 --- a/crates/but-claude/src/bridge.rs +++ b/crates/but-claude/src/bridge.rs @@ -41,8 +41,8 @@ use tokio::{ }; use crate::{ - ClaudeMessage, ClaudeMessageContent, ClaudeUserParams, GitButlerMessage, PermissionMode, - PromptAttachment, ThinkingLevel, Transcript, UserInput, + ClaudeMessage, ClaudeOutput, ClaudeUserParams, MessagePayload, PermissionMode, + PromptAttachment, SystemMessage, ThinkingLevel, Transcript, UserInput, claude_config::fmt_claude_settings, claude_mcp::{BUT_SECURITY_MCP, ClaudeMcpConfig}, claude_settings::ClaudeSettings, @@ -167,11 +167,9 @@ impl Claudes { broadcaster.clone(), rule.session_id, stack_id, - ClaudeMessageContent::GitButlerMessage( - crate::GitButlerMessage::UnhandledException { - message: format!("{res}"), - }, - ), + MessagePayload::System(crate::SystemMessage::UnhandledException { + message: format!("{res}"), + }), ) .await; } @@ -213,11 +211,11 @@ impl Claudes { let mut ctx = ctx.lock().await; let messages = list_messages_by_session(&mut ctx, session.id)?; - if let Some(ClaudeMessage { content, .. }) = messages.last() { - match content { - ClaudeMessageContent::GitButlerMessage(GitButlerMessage::CompactFinished { - summary, - }) => Some(summary.clone()), + if let Some(ClaudeMessage { payload, .. }) = messages.last() { + match payload { + MessagePayload::System(SystemMessage::CompactFinished { summary }) => { + Some(summary.clone()) + } _ => None, } } else { @@ -234,7 +232,7 @@ impl Claudes { broadcaster.clone(), session_id, stack_id, - ClaudeMessageContent::UserInput(UserInput { + MessagePayload::User(UserInput { message: user_params.message.clone(), // Original user message for display attachments: user_params.attachments.clone(), }), @@ -315,7 +313,7 @@ async fn handle_exit( broadcaster.clone(), session_id, stack_id, - ClaudeMessageContent::GitButlerMessage(crate::GitButlerMessage::ClaudeExit { + MessagePayload::System(crate::SystemMessage::ClaudeExit { code: exit_status.code().unwrap_or(0), message: buf.clone(), }), @@ -347,7 +345,7 @@ async fn handle_exit( broadcaster.clone(), session_id, stack_id, - ClaudeMessageContent::GitButlerMessage(crate::GitButlerMessage::UserAbort), + MessagePayload::System(crate::SystemMessage::UserAbort), ) .await?; } @@ -612,7 +610,9 @@ fn spawn_response_streaming( first = false; } - let message_content = ClaudeMessageContent::ClaudeOutput(parsed_event.clone()); + let message_content = MessagePayload::Claude(ClaudeOutput { + data: parsed_event.clone(), + }); send_claude_message( &mut ctx, broadcaster.clone(), diff --git a/crates/but-claude/src/compact.rs b/crates/but-claude/src/compact.rs index 2e610ab80f..44870f6415 100644 --- a/crates/but-claude/src/compact.rs +++ b/crates/but-claude/src/compact.rs @@ -26,7 +26,7 @@ use tokio::{ }; use crate::{ - ClaudeMessageContent, ClaudeSession, GitButlerMessage, Transcript, + ClaudeSession, MessagePayload, SystemMessage, Transcript, bridge::{Claude, Claudes}, db, rules::list_claude_assignment_rules, @@ -98,11 +98,9 @@ impl Claudes { broadcaster.clone(), rule.session_id, stack_id, - ClaudeMessageContent::GitButlerMessage( - crate::GitButlerMessage::UnhandledException { - message: format!("{res}"), - }, - ), + MessagePayload::System(crate::SystemMessage::UnhandledException { + message: format!("{res}"), + }), ) .await; } @@ -143,7 +141,7 @@ impl Claudes { broadcaster.clone(), rule.session_id, stack_id, - ClaudeMessageContent::GitButlerMessage(GitButlerMessage::CompactStart), + MessagePayload::System(SystemMessage::CompactStart), ) .await?; } @@ -155,9 +153,7 @@ impl Claudes { broadcaster.clone(), rule.session_id, stack_id, - ClaudeMessageContent::GitButlerMessage(GitButlerMessage::CompactFinished { - summary, - }), + MessagePayload::System(SystemMessage::CompactFinished { summary }), ) .await?; } @@ -187,10 +183,10 @@ impl Claudes { }; // Find the last result message - let Some(output) = messages.into_iter().rev().find_map(|m| match m.content { - ClaudeMessageContent::ClaudeOutput(o) => { - if o["type"].as_str() == Some("assistant") { - Some(o) + let Some(output) = messages.into_iter().rev().find_map(|m| match m.payload { + MessagePayload::Claude(o) => { + if o.data["type"].as_str() == Some("assistant") { + Some(o.data) } else { None } diff --git a/crates/but-claude/src/db.rs b/crates/but-claude/src/db.rs index 284ac1a39f..8161ae4630 100644 --- a/crates/but-claude/src/db.rs +++ b/crates/but-claude/src/db.rs @@ -111,17 +111,17 @@ pub fn delete_session_and_messages_by_id( Ok(()) } -/// Creates a new ClaudeMessage with the provided session_id and content, and saves it to the database. +/// Creates a new ClaudeMessage with the provided session_id and payload, and saves it to the database. pub fn save_new_message( ctx: &mut CommandContext, session_id: Uuid, - content: crate::ClaudeMessageContent, + payload: crate::MessagePayload, ) -> anyhow::Result { let message = crate::ClaudeMessage { id: Uuid::new_v4(), session_id, created_at: chrono::Utc::now().naive_utc(), - content, + payload, }; ctx.db()? .claude_messages() @@ -130,6 +130,7 @@ pub fn save_new_message( } /// Lists all messages associated with a given session ID from the database. +/// Messages that fail to deserialize are skipped and logged as warnings. pub fn list_messages_by_session( ctx: &mut CommandContext, session_id: Uuid, @@ -198,7 +199,11 @@ impl TryFrom for but_db::ClaudeSession { } #[derive(Debug, Clone, Copy, strum::EnumString, strum::Display)] -enum ClaudeMessageDbContentType { +enum MessagePayloadDbType { + Claude, + User, + System, + // Legacy names for backward compatibility ClaudeOutput, UserInput, GitButlerMessage, @@ -207,23 +212,40 @@ enum ClaudeMessageDbContentType { impl TryFrom for crate::ClaudeMessage { type Error = anyhow::Error; fn try_from(value: but_db::ClaudeMessage) -> Result { - let content_type: ClaudeMessageDbContentType = value.content_type.parse()?; - let content = match content_type { - ClaudeMessageDbContentType::ClaudeOutput => { - crate::ClaudeMessageContent::ClaudeOutput(serde_json::from_str(&value.content)?) + let payload_type: MessagePayloadDbType = value.content_type.parse()?; + let payload = match payload_type { + MessagePayloadDbType::Claude => { + let data: serde_json::Value = serde_json::from_str(&value.content)?; + crate::MessagePayload::Claude(crate::ClaudeOutput { data }) } - ClaudeMessageDbContentType::UserInput => { - crate::ClaudeMessageContent::UserInput(serde_json::from_str(&value.content)?) + MessagePayloadDbType::ClaudeOutput => { + crate::legacy::ClaudeMessageContent::ClaudeOutput(serde_json::from_str( + &value.content, + )?) + .into() } - ClaudeMessageDbContentType::GitButlerMessage => { - crate::ClaudeMessageContent::GitButlerMessage(serde_json::from_str(&value.content)?) + MessagePayloadDbType::User => { + crate::MessagePayload::User(serde_json::from_str(&value.content)?) + } + MessagePayloadDbType::UserInput => crate::legacy::ClaudeMessageContent::UserInput( + serde_json::from_str(&value.content)?, + ) + .into(), + MessagePayloadDbType::System => { + crate::MessagePayload::System(serde_json::from_str(&value.content)?) + } + MessagePayloadDbType::GitButlerMessage => { + crate::legacy::ClaudeMessageContent::GitButlerMessage(serde_json::from_str( + &value.content, + )?) + .into() } }; Ok(crate::ClaudeMessage { id: Uuid::parse_str(&value.id)?, session_id: Uuid::parse_str(&value.session_id)?, created_at: value.created_at, - content, + payload, }) } } @@ -231,18 +253,18 @@ impl TryFrom for crate::ClaudeMessage { impl TryFrom for but_db::ClaudeMessage { type Error = anyhow::Error; fn try_from(value: crate::ClaudeMessage) -> Result { - let (content_type, content) = match value.content { - crate::ClaudeMessageContent::ClaudeOutput(value) => { - let value = serde_json::to_string(&value)?; - (ClaudeMessageDbContentType::ClaudeOutput, value) + let (payload_type, content) = match value.payload { + crate::MessagePayload::Claude(output) => { + let content = serde_json::to_string(&output.data)?; + (MessagePayloadDbType::Claude, content) } - crate::ClaudeMessageContent::UserInput(value) => { - let value = serde_json::to_string(&value)?; - (ClaudeMessageDbContentType::UserInput, value) + crate::MessagePayload::User(input) => { + let content = serde_json::to_string(&input)?; + (MessagePayloadDbType::User, content) } - crate::ClaudeMessageContent::GitButlerMessage(value) => { - let value = serde_json::to_string(&value)?; - (ClaudeMessageDbContentType::GitButlerMessage, value) + crate::MessagePayload::System(msg) => { + let content = serde_json::to_string(&msg)?; + (MessagePayloadDbType::System, content) } }; @@ -250,7 +272,7 @@ impl TryFrom for but_db::ClaudeMessage { id: value.id.to_string(), session_id: value.session_id.to_string(), created_at: value.created_at, - content_type: content_type.to_string(), + content_type: payload_type.to_string(), content, }) } @@ -283,3 +305,361 @@ impl TryFrom for but_db::ClaudePermissionRequest }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gitbutler_message_claude_exit() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440000".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "GitButlerMessage".to_string(), + content: r#"{ + "subject": { + "code": 0, + "message": "" + }, + "type": "claudeExit" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + assert_eq!( + message.id, + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() + ); + assert_eq!( + message.session_id, + Uuid::parse_str("650e8400-e29b-41d4-a716-446655440000").unwrap() + ); + + match message.payload { + crate::MessagePayload::System(crate::SystemMessage::ClaudeExit { code, message }) => { + assert_eq!(code, 0); + assert_eq!(message, ""); + } + _ => panic!("Expected SystemMessage::ClaudeExit"), + } + } + + #[test] + fn test_gitbutler_message_user_abort() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440001".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440001".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "GitButlerMessage".to_string(), + content: r#"{ + "type": "userAbort" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::System(crate::SystemMessage::UserAbort) => {} + _ => panic!("Expected SystemMessage::UserAbort"), + } + } + + #[test] + fn test_user_input_without_attachments() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440002".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440002".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "UserInput".to_string(), + content: r#"{ + "message": "Okay but i need a test where `attachments` is not set at all" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::User(user_input) => { + assert_eq!( + user_input.message, + "Okay but i need a test where `attachments` is not set at all" + ); + assert!(user_input.attachments.is_none()); + } + _ => panic!("Expected MessagePayload::User"), + } + } + + #[test] + fn test_user_input_with_empty_attachments_array() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440003".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440003".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "UserInput".to_string(), + content: r#"{ + "attachments": [], + "message": "Message with empty attachments" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::User(user_input) => { + assert_eq!(user_input.message, "Message with empty attachments"); + assert!(user_input.attachments.is_some()); + assert_eq!(user_input.attachments.unwrap().len(), 0); + } + _ => panic!("Expected MessagePayload::User"), + } + } + + #[test] + fn test_user_input_with_file_attachment() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440004".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440004".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "UserInput".to_string(), + content: r#"{ + "attachments": [ + { + "path": "ASSETS_LICENSE", + "type": "file" + } + ], + "message": "Check this file out" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::User(user_input) => { + assert_eq!(user_input.message, "Check this file out"); + assert!(user_input.attachments.is_some()); + let attachments = user_input.attachments.unwrap(); + assert_eq!(attachments.len(), 1); + match &attachments[0] { + crate::PromptAttachment::File(file_att) => { + assert_eq!(file_att.path, "ASSETS_LICENSE"); + assert!(file_att.commit_id.is_none()); + } + _ => panic!("Expected File attachment"), + } + } + _ => panic!("Expected MessagePayload::User"), + } + } + + #[test] + fn test_claude_output() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440005".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440005".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "ClaudeOutput".to_string(), + content: r#"{ + "message": { + "content": [ + { + "text": "Perfect! Now let's run the tests to verify they all pass:", + "type": "text" + } + ], + "id": "msg_01Eu1HSLVLWD64FDD1j8KGgQ", + "model": "claude-sonnet-4-5-20250929", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 1327 + }, + "cache_creation_input_tokens": 1327, + "cache_read_input_tokens": 28563, + "input_tokens": 4, + "output_tokens": 1, + "service_tier": "standard" + } + }, + "parent_tool_use_id": null, + "session_id": "a9a3c83b-fdf4-4eee-964e-1043c7b8ac0b", + "type": "assistant", + "uuid": "aab0b9a3-be9f-4ca0-adac-3cdaeebf889d" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::Claude(claude_output) => { + // Verify it's valid JSON and contains expected fields + assert!(claude_output.data.is_object()); + assert!(claude_output.data.get("message").is_some()); + assert!(claude_output.data.get("type").is_some()); + assert_eq!( + claude_output.data.get("type").unwrap().as_str().unwrap(), + "assistant" + ); + } + _ => panic!("Expected MessagePayload::Claude"), + } + } + + #[test] + fn test_new_claude_type() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440006".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440006".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "Claude".to_string(), + content: r#"{ + "content": [ + { + "text": "Some claude response", + "type": "text" + } + ], + "id": "msg_123", + "role": "assistant" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::Claude(claude_output) => { + assert!(claude_output.data.is_object()); + assert!(claude_output.data.get("content").is_some()); + } + _ => panic!("Expected MessagePayload::Claude"), + } + } + + #[test] + fn test_new_user_type() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440007".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440007".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "User".to_string(), + content: r#"{ + "message": "Test user message", + "attachments": null + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::User(user_input) => { + assert_eq!(user_input.message, "Test user message"); + assert!(user_input.attachments.is_none()); + } + _ => panic!("Expected MessagePayload::User"), + } + } + + #[test] + fn test_new_system_type() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440008".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440008".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "System".to_string(), + content: r#"{ + "type": "userAbort" + }"# + .to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_ok()); + + let message = result.unwrap(); + match message.payload { + crate::MessagePayload::System(crate::SystemMessage::UserAbort) => {} + _ => panic!("Expected MessagePayload::System(UserAbort)"), + } + } + + #[test] + fn test_invalid_content_type() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440000".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "InvalidType".to_string(), + content: r#"{"message": "test"}"#.to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_json_content() { + let db_message = but_db::ClaudeMessage { + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + session_id: "650e8400-e29b-41d4-a716-446655440000".to_string(), + created_at: chrono::DateTime::from_timestamp(1234567890, 0) + .unwrap() + .naive_utc(), + content_type: "User".to_string(), + content: "not valid json".to_string(), + }; + + let result: Result = db_message.try_into(); + assert!(result.is_err()); + } +} diff --git a/crates/but-claude/src/legacy.rs b/crates/but-claude/src/legacy.rs new file mode 100644 index 0000000000..b6f630505c --- /dev/null +++ b/crates/but-claude/src/legacy.rs @@ -0,0 +1,164 @@ +//! Legacy types for deserializing older Claude message formats. +//! + +use serde::{Deserialize, Serialize}; + +/// Represents the kind of content in a Claude message. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", tag = "type", content = "subject")] +pub enum ClaudeMessageContent { + /// Came from Claude standard out stream + ClaudeOutput(serde_json::Value), + /// Inserted via GitButler (what the user typed) + UserInput(UserInput), + /// Metadata provided by GitButler around the Claude Code statuts + GitButlerMessage(GitButlerMessage), +} + +impl From for crate::MessagePayload { + fn from(value: ClaudeMessageContent) -> Self { + match value { + ClaudeMessageContent::ClaudeOutput(data) => { + crate::MessagePayload::Claude(crate::ClaudeOutput { data }) + } + ClaudeMessageContent::UserInput(user_input) => { + crate::MessagePayload::User(user_input.into()) + } + ClaudeMessageContent::GitButlerMessage(gb_message) => { + crate::MessagePayload::System(gb_message.into()) + } + } + } +} + +/// Metadata provided by GitButler. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", tag = "type", content = "subject")] +pub enum GitButlerMessage { + /// Claude code has exited naturally. + ClaudeExit { + code: i32, + message: String, + }, + /// Claude code has exited due to a user abortion. + UserAbort, + UnhandledException { + message: String, + }, + /// Compact operation has started. + CompactStart, + /// Compact operation has finished. + CompactFinished { + summary: String, + }, +} + +impl From for crate::SystemMessage { + fn from(value: GitButlerMessage) -> Self { + match value { + GitButlerMessage::ClaudeExit { code, message } => { + crate::SystemMessage::ClaudeExit { code, message } + } + GitButlerMessage::UserAbort => crate::SystemMessage::UserAbort, + GitButlerMessage::UnhandledException { message } => { + crate::SystemMessage::UnhandledException { message } + } + GitButlerMessage::CompactStart => crate::SystemMessage::CompactStart, + GitButlerMessage::CompactFinished { summary } => { + crate::SystemMessage::CompactFinished { summary } + } + } + } +} + +/// Represents user input in a Claude session. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UserInput { + /// The user message + pub message: String, + /// Optional attached file references + pub attachments: Option>, +} + +impl From for crate::UserInput { + fn from(value: UserInput) -> Self { + crate::UserInput { + message: value.message, + attachments: value + .attachments + .map(|atts| atts.into_iter().map(|a| a.into()).collect()), + } + } +} + +/// Represents a file attachment with full content (used in API input). +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum PromptAttachment { + Lines(LinesAttachment), + File(FileAttachment), + Commit(CommitAttachment), +} + +impl From for crate::PromptAttachment { + fn from(value: PromptAttachment) -> Self { + match value { + PromptAttachment::Lines(lines) => crate::PromptAttachment::Lines(lines.into()), + PromptAttachment::File(file) => crate::PromptAttachment::File(file.into()), + PromptAttachment::Commit(commit) => crate::PromptAttachment::Commit(commit.into()), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FileAttachment { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + commit_id: Option, +} + +impl From for crate::FileAttachment { + fn from(value: FileAttachment) -> Self { + crate::FileAttachment { + commit_id: value.commit_id, + path: value.path, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LinesAttachment { + path: String, + start: usize, + end: usize, + #[serde(skip_serializing_if = "Option::is_none")] + commit_id: Option, +} + +impl From for crate::LinesAttachment { + fn from(value: LinesAttachment) -> Self { + crate::LinesAttachment { + commit_id: value.commit_id, + path: value.path, + start: value.start, + end: value.end, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CommitAttachment { + commit_id: String, +} + +impl From for crate::CommitAttachment { + fn from(value: CommitAttachment) -> Self { + crate::CommitAttachment { + commit_id: value.commit_id, + } + } +} diff --git a/crates/but-claude/src/lib.rs b/crates/but-claude/src/lib.rs index b8e65bb95c..347f8be8eb 100644 --- a/crates/but-claude/src/lib.rs +++ b/crates/but-claude/src/lib.rs @@ -20,6 +20,7 @@ pub(crate) mod claude_transcript; pub use claude_transcript::Transcript; pub mod db; pub mod hooks; +pub(crate) mod legacy; pub mod mcp; pub mod notifications; pub mod prompt_templates; @@ -53,38 +54,36 @@ pub struct ClaudeMessage { session_id: Uuid, /// The timestamp when the message was created. created_at: chrono::NaiveDateTime, - /// The content of the message, which can be either output from Claude or user input. - content: ClaudeMessageContent, + /// The payload of the message from different sources. + payload: MessagePayload, } -/// Represents the kind of content in a Claude message. +/// The actual message payload from different sources. #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", tag = "type", content = "subject")] -pub enum ClaudeMessageContent { - /// Came from Claude standard out stream - ClaudeOutput(serde_json::Value), - /// Inserted via GitButler (what the user typed) - UserInput(UserInput), - /// Metadata provided by GitButler around the Claude Code statuts - GitButlerMessage(GitButlerMessage), +#[serde(rename_all = "camelCase")] +pub enum MessagePayload { + /// Output from Claude Code CLI stdout stream + Claude(ClaudeOutput), + /// Input provided by the user + User(UserInput), + /// System message from GitButler about the session + System(SystemMessage), } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FileAttachment { - path: String, - #[serde(skip_serializing_if = "Option::is_none")] commit_id: Option, + path: String, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct LinesAttachment { + commit_id: Option, path: String, start: usize, end: usize, - #[serde(skip_serializing_if = "Option::is_none")] - commit_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -95,13 +94,21 @@ pub struct CommitAttachment { /// Represents a file attachment with full content (used in API input). #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", tag = "type")] +#[serde(rename_all = "camelCase")] pub enum PromptAttachment { Lines(LinesAttachment), File(FileAttachment), Commit(CommitAttachment), } +/// Raw output from Claude API +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeOutput { + /// Raw JSON value from Claude API streaming output + pub data: serde_json::Value, +} + /// Represents user input in a Claude session. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -113,10 +120,10 @@ pub struct UserInput { pub attachments: Option>, } -/// Metadata provided by GitButler. +/// System messages from GitButler about the Claude session state. #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", tag = "type", content = "subject")] -pub enum GitButlerMessage { +#[serde(rename_all = "camelCase", tag = "type")] +pub enum SystemMessage { /// Claude code has exited naturally. ClaudeExit { code: i32, @@ -226,7 +233,7 @@ pub async fn send_claude_message( broadcaster: Arc>, session_id: uuid::Uuid, stack_id: StackId, - content: ClaudeMessageContent, + content: MessagePayload, ) -> Result<()> { let message = db::save_new_message(ctx, session_id, content.clone())?; let project_id = ctx.project().id;