Save assistant message (vibe-kanban) (#47)

* Task attempt bc7ef54d-3955-4902-8086-6676c7924f1b - Final changes

* Task attempt bc7ef54d-3955-4902-8086-6676c7924f1b - Final changes

* Task attempt bc7ef54d-3955-4902-8086-6676c7924f1b - Final changes

* Task attempt bc7ef54d-3955-4902-8086-6676c7924f1b - Final changes

* Task attempt bc7ef54d-3955-4902-8086-6676c7924f1b - Final changes

* Cargo fmt
This commit is contained in:
Louis Knight-Webb
2025-07-01 17:45:12 +01:00
committed by GitHub
parent e22988da51
commit 2aed3c06c8
10 changed files with 247 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE id = $1", "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n summary,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -29,14 +29,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "summary",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -48,9 +53,10 @@
false, false,
true, true,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "3b65c0f6215229f3c8d487c204bca5a1a8e327d9b469b47d833befa95377dfab" "hash": "417a8b1ff4e51de82aea0159a3b97932224dc325b23476cb84153d690227fd8b"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC", "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n summary,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE execution_process_id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -29,14 +29,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "summary",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -48,9 +53,10 @@
false, false,
true, true,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "06ca282915d0db9125769b1bca92f7a5bd7f81ad8faf0f9fcbb5f1c2d35dd67f" "hash": "89a6db6a4b318736dca5c1b8921631a2ba93d95a27bcbbef0058d4726af8a733"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE executor_sessions \n SET summary = $1, updated_at = datetime('now') \n WHERE execution_process_id = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "8a67b3b3337248f06a57bdf8a908f7ef23177431eaed82dc08c94c3e5944340e"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE execution_process_id = $1", "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n summary,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM executor_sessions \n WHERE id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -29,14 +29,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "summary",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -48,9 +53,10 @@
false, false,
true, true,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "0468aa522ed7fd2675bcf278f6be38ce16752cb73adf0fafc5b497a88f32f531" "hash": "a31fff84f3b8e532fd1160447d89d700f06ae08821fee00c9a5b60492b05259c"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "INSERT INTO executor_sessions (\n id, task_attempt_id, execution_process_id, session_id, prompt, \n created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"", "query": "INSERT INTO executor_sessions (\n id, task_attempt_id, execution_process_id, session_id, prompt, summary,\n created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n execution_process_id as \"execution_process_id!: Uuid\", \n session_id, \n prompt,\n summary,\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -29,18 +29,23 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "summary",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Right": 7 "Right": 8
}, },
"nullable": [ "nullable": [
true, true,
@@ -48,9 +53,10 @@
false, false,
true, true,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "a528a9926fab1c819a5a1fa1cde87ea9d354da0873af22e888d0bf8e0c7f306a" "hash": "d0d71fd65c0f9f1bd0df2588d313085452e24260f48844b588152b532ca5d6e7"
} }

View File

@@ -0,0 +1,2 @@
-- Add summary column to executor_sessions table
ALTER TABLE executor_sessions ADD COLUMN summary TEXT;

View File

@@ -17,9 +17,11 @@ use crate::{
async fn commit_execution_changes( async fn commit_execution_changes(
worktree_path: &str, worktree_path: &str,
attempt_id: Uuid, attempt_id: Uuid,
summary: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Run git operations in a blocking task since git2 is synchronous // Run git operations in a blocking task since git2 is synchronous
let worktree_path = worktree_path.to_string(); let worktree_path = worktree_path.to_string();
let summary = summary.map(|s| s.to_string());
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let worktree_repo = Repository::open(&worktree_path)?; let worktree_repo = Repository::open(&worktree_path)?;
@@ -55,7 +57,11 @@ async fn commit_execution_changes(
let tree = worktree_repo.find_tree(tree_id)?; let tree = worktree_repo.find_tree(tree_id)?;
// Create commit for the changes // Create commit for the changes
let commit_message = format!("Task attempt {} - Final changes", attempt_id); let commit_message = if let Some(ref summary_msg) = summary {
summary_msg.clone()
} else {
format!("Task attempt {} - Final changes", attempt_id)
};
worktree_repo.commit( worktree_repo.commit(
Some("HEAD"), Some("HEAD"),
&signature, &signature,
@@ -580,7 +586,7 @@ async fn handle_coding_agent_completion(
app_state: &AppState, app_state: &AppState,
task_attempt_id: Uuid, task_attempt_id: Uuid,
execution_process_id: Uuid, execution_process_id: Uuid,
_execution_process: ExecutionProcess, execution_process: ExecutionProcess,
success: bool, success: bool,
exit_code: Option<i64>, exit_code: Option<i64>,
) { ) {
@@ -590,6 +596,37 @@ async fn handle_coding_agent_completion(
String::new() String::new()
}; };
// Extract and store assistant message from execution logs
let summary = if let Some(stdout) = &execution_process.stdout {
if let Some(assistant_message) = crate::executor::parse_assistant_message_from_logs(stdout)
{
if let Err(e) = crate::models::executor_session::ExecutorSession::update_summary(
&app_state.db_pool,
execution_process_id,
&assistant_message,
)
.await
{
tracing::error!(
"Failed to update summary for execution process {}: {}",
execution_process_id,
e
);
None
} else {
tracing::info!(
"Successfully stored summary for execution process {}",
execution_process_id
);
Some(assistant_message)
}
} else {
None
}
} else {
None
};
// Play sound notification if enabled // Play sound notification if enabled
if app_state.get_sound_alerts_enabled().await { if app_state.get_sound_alerts_enabled().await {
let sound_file = app_state.get_sound_file().await; let sound_file = app_state.get_sound_file().await;
@@ -612,7 +649,12 @@ async fn handle_coding_agent_completion(
TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await
{ {
// Commit any unstaged changes after execution completion // Commit any unstaged changes after execution completion
if let Err(e) = commit_execution_changes(&task_attempt.worktree_path, task_attempt_id).await if let Err(e) = commit_execution_changes(
&task_attempt.worktree_path,
task_attempt_id,
summary.as_deref(),
)
.await
{ {
tracing::error!( tracing::error!(
"Failed to commit execution changes for attempt {}: {}", "Failed to commit execution changes for attempt {}: {}",

View File

@@ -436,6 +436,90 @@ pub async fn stream_output_to_db(
} }
} }
/// Parse assistant message from executor logs (JSONL format)
pub fn parse_assistant_message_from_logs(logs: &str) -> Option<String> {
use serde_json::Value;
let mut last_assistant_message = None;
for line in logs.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<Value>(trimmed) {
// Check for Claude format: {"type":"assistant","message":{"content":[...]}}
if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) {
if msg_type == "assistant" {
if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
// Extract text content from Claude assistant message
let mut text_parts = Vec::new();
for content_item in content {
if let Some(content_type) =
content_item.get("type").and_then(|t| t.as_str())
{
if content_type == "text" {
if let Some(text) =
content_item.get("text").and_then(|t| t.as_str())
{
text_parts.push(text);
}
}
}
}
if !text_parts.is_empty() {
last_assistant_message = Some(text_parts.join("\n"));
}
}
}
continue;
}
}
// Check for AMP format: {"type":"messages","messages":[[1,{"role":"assistant",...}]]}
if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) {
for message_entry in messages {
if let Some(message_data) = message_entry.as_array().and_then(|arr| arr.get(1))
{
if let Some(role) = message_data.get("role").and_then(|r| r.as_str()) {
if role == "assistant" {
if let Some(content) =
message_data.get("content").and_then(|c| c.as_array())
{
// Extract text content from AMP assistant message
let mut text_parts = Vec::new();
for content_item in content {
if let Some(content_type) =
content_item.get("type").and_then(|t| t.as_str())
{
if content_type == "text" {
if let Some(text) = content_item
.get("text")
.and_then(|t| t.as_str())
{
text_parts.push(text);
}
}
}
}
if !text_parts.is_empty() {
last_assistant_message = Some(text_parts.join("\n"));
}
}
}
}
}
}
}
}
}
last_assistant_message
}
/// Parse session_id from Claude or thread_id from Amp from the first JSONL line /// Parse session_id from Claude or thread_id from Amp from the first JSONL line
fn parse_session_id_from_line(line: &str) -> Option<String> { fn parse_session_id_from_line(line: &str) -> Option<String> {
use serde_json::Value; use serde_json::Value;
@@ -502,4 +586,35 @@ mod tests {
assert_eq!(parse_session_id_from_line(""), None); assert_eq!(parse_session_id_from_line(""), None);
assert_eq!(parse_session_id_from_line(" "), None); assert_eq!(parse_session_id_from_line(" "), None);
} }
#[test]
fn test_parse_assistant_message_from_logs() {
// Test AMP format
let amp_logs = r#"{"type":"initial","threadID":"T-e7af5516-e5a5-4754-8e34-810dc658716e"}
{"type":"messages","messages":[[0,{"role":"user","content":[{"type":"text","text":"Task title: Test task"}],"meta":{"sentAt":1751385490573}}]],"toolResults":[]}
{"type":"messages","messages":[[1,{"role":"assistant","content":[{"type":"thinking","thinking":"Testing"},{"type":"text","text":"The Pythagorean theorem states that in a right triangle, the square of the hypotenuse equals the sum of squares of the other two sides: **a² + b² = c²**."}],"state":{"type":"complete","stopReason":"end_turn"}}]],"toolResults":[]}
{"type":"state","state":"idle"}
{"type":"shutdown"}"#;
let result = parse_assistant_message_from_logs(amp_logs);
assert!(result.is_some());
assert!(result.as_ref().unwrap().contains("Pythagorean theorem"));
assert!(result.as_ref().unwrap().contains("a² + b² = c²"));
}
#[test]
fn test_parse_claude_assistant_message_from_logs() {
// Test Claude format
let claude_logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114","tools":[],"model":"claude-sonnet-4-20250514"}
{"type":"assistant","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll explain the Pythagorean theorem for you.\n\nThe Pythagorean theorem states that in a right triangle, the square of the hypotenuse equals the sum of the squares of the other two sides.\n\n**Formula:** a² + b² = c²"}],"stop_reason":null},"session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":6059,"result":"Final result"}"#;
let result = parse_assistant_message_from_logs(claude_logs);
assert!(result.is_some());
assert!(result.as_ref().unwrap().contains("Pythagorean theorem"));
assert!(result
.as_ref()
.unwrap()
.contains("**Formula:** a² + b² = c²"));
}
} }

View File

@@ -12,6 +12,7 @@ pub struct ExecutorSession {
pub execution_process_id: Uuid, pub execution_process_id: Uuid,
pub session_id: Option<String>, // External session ID from Claude/Amp pub session_id: Option<String>, // External session ID from Claude/Amp
pub prompt: Option<String>, // The prompt sent to the executor pub prompt: Option<String>, // The prompt sent to the executor
pub summary: Option<String>, // Final assistant message/summary
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -30,6 +31,7 @@ pub struct CreateExecutorSession {
pub struct UpdateExecutorSession { pub struct UpdateExecutorSession {
pub session_id: Option<String>, pub session_id: Option<String>,
pub prompt: Option<String>, pub prompt: Option<String>,
pub summary: Option<String>,
} }
impl ExecutorSession { impl ExecutorSession {
@@ -44,6 +46,7 @@ impl ExecutorSession {
execution_process_id as "execution_process_id!: Uuid", execution_process_id as "execution_process_id!: Uuid",
session_id, session_id,
prompt, prompt,
summary,
created_at as "created_at!: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>",
updated_at as "updated_at!: DateTime<Utc>" updated_at as "updated_at!: DateTime<Utc>"
FROM executor_sessions FROM executor_sessions
@@ -67,6 +70,7 @@ impl ExecutorSession {
execution_process_id as "execution_process_id!: Uuid", execution_process_id as "execution_process_id!: Uuid",
session_id, session_id,
prompt, prompt,
summary,
created_at as "created_at!: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>",
updated_at as "updated_at!: DateTime<Utc>" updated_at as "updated_at!: DateTime<Utc>"
FROM executor_sessions FROM executor_sessions
@@ -91,6 +95,7 @@ impl ExecutorSession {
execution_process_id as "execution_process_id!: Uuid", execution_process_id as "execution_process_id!: Uuid",
session_id, session_id,
prompt, prompt,
summary,
created_at as "created_at!: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>",
updated_at as "updated_at!: DateTime<Utc>" updated_at as "updated_at!: DateTime<Utc>"
FROM executor_sessions FROM executor_sessions
@@ -113,16 +118,17 @@ impl ExecutorSession {
sqlx::query_as!( sqlx::query_as!(
ExecutorSession, ExecutorSession,
r#"INSERT INTO executor_sessions ( r#"INSERT INTO executor_sessions (
id, task_attempt_id, execution_process_id, session_id, prompt, id, task_attempt_id, execution_process_id, session_id, prompt, summary,
created_at, updated_at created_at, updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING RETURNING
id as "id!: Uuid", id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid", task_attempt_id as "task_attempt_id!: Uuid",
execution_process_id as "execution_process_id!: Uuid", execution_process_id as "execution_process_id!: Uuid",
session_id, session_id,
prompt, prompt,
summary,
created_at as "created_at!: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>",
updated_at as "updated_at!: DateTime<Utc>""#, updated_at as "updated_at!: DateTime<Utc>""#,
session_id, session_id,
@@ -130,8 +136,9 @@ impl ExecutorSession {
data.execution_process_id, data.execution_process_id,
None::<String>, // session_id initially None until parsed from output None::<String>, // session_id initially None until parsed from output
data.prompt, data.prompt,
now, // created_at None::<String>, // summary initially None
now // updated_at now, // created_at
now // updated_at
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
@@ -176,6 +183,25 @@ impl ExecutorSession {
Ok(()) Ok(())
} }
/// Update executor session summary
pub async fn update_summary(
pool: &SqlitePool,
execution_process_id: Uuid,
summary: &str,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"UPDATE executor_sessions
SET summary = $1, updated_at = datetime('now')
WHERE execution_process_id = $2"#,
summary,
execution_process_id
)
.execute(pool)
.await?;
Ok(())
}
/// Delete executor sessions for a task attempt (cleanup) /// Delete executor sessions for a task attempt (cleanup)
#[allow(dead_code)] #[allow(dead_code)]
pub async fn delete_by_task_attempt_id( pub async fn delete_by_task_attempt_id(

View File

@@ -94,11 +94,11 @@ export type CreateExecutionProcess = { task_attempt_id: string, process_type: Ex
export type UpdateExecutionProcess = { status: ExecutionProcessStatus | null, exit_code: bigint | null, completed_at: string | null, }; export type UpdateExecutionProcess = { status: ExecutionProcessStatus | null, exit_code: bigint | null, completed_at: string | null, };
export type ExecutorSession = { id: string, task_attempt_id: string, execution_process_id: string, session_id: string | null, prompt: string | null, created_at: string, updated_at: string, }; export type ExecutorSession = { id: string, task_attempt_id: string, execution_process_id: string, session_id: string | null, prompt: string | null, summary: string | null, created_at: string, updated_at: string, };
export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, }; export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, };
export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, }; export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, };
// Generated constants // Generated constants
export const EXECUTOR_TYPES: string[] = [ export const EXECUTOR_TYPES: string[] = [