From f253b092876c88922241c51428a519dc5ce4704c Mon Sep 17 00:00:00 2001 From: Gracjan Kubicki Date: Tue, 19 May 2026 12:43:55 +0200 Subject: [PATCH 1/3] Fix stored tool conversation replay order --- src/Storage/DatabaseConversationStore.php | 6 ++- .../Storage/DatabaseConversationStoreTest.php | 45 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/Storage/DatabaseConversationStore.php b/src/Storage/DatabaseConversationStore.php index 2b31b4fc7..b426595d4 100644 --- a/src/Storage/DatabaseConversationStore.php +++ b/src/Storage/DatabaseConversationStore.php @@ -159,7 +159,7 @@ public function getLatestConversationMessages(string $conversationId, int $limit $messages = []; $messages[] = new AssistantMessage( - $record->content ?: '', + '', $toolCalls->map(fn ($toolCall) => new ToolCall( id: $toolCall['id'], name: $toolCall['name'], @@ -182,6 +182,10 @@ public function getLatestConversationMessages(string $conversationId, int $limit ); } + if ($record->content !== '') { + $messages[] = new AssistantMessage($record->content); + } + return $messages; } diff --git a/tests/Feature/Storage/DatabaseConversationStoreTest.php b/tests/Feature/Storage/DatabaseConversationStoreTest.php index 81df3099a..5a7978f7f 100644 --- a/tests/Feature/Storage/DatabaseConversationStoreTest.php +++ b/tests/Feature/Storage/DatabaseConversationStoreTest.php @@ -169,11 +169,52 @@ $messages = $store->getLatestConversationMessages($conversationId, 10); - expect($messages)->toHaveCount(2) + expect($messages)->toHaveCount(3) ->and($messages[0])->toBeInstanceOf(AssistantMessage::class) ->and($messages[0]->toolCalls->keys()->all())->toBe([0, 1]) ->and($messages[1])->toBeInstanceOf(ToolResultMessage::class) - ->and($messages[1]->toolResults->keys()->all())->toBe([0, 1]); + ->and($messages[1]->toolResults->keys()->all())->toBe([0, 1]) + ->and($messages[2])->toBeInstanceOf(AssistantMessage::class) + ->and($messages[2]->content)->toBe('The order has shipped.'); +}); + +test('it replays stored tool conversations before the final assistant response', function () { + $store = new DatabaseConversationStore; + $conversationId = $store->storeConversation(1, 'Tool conversation'); + + DB::table('agent_conversation_messages')->insert([ + 'id' => 'message-1', + 'conversation_id' => $conversationId, + 'user_id' => 1, + 'agent' => ToolUsingAgent::class, + 'role' => 'assistant', + 'content' => 'The order has shipped.', + 'attachments' => '[]', + 'tool_calls' => json_encode([ + ['id' => 'call-1', 'name' => 'lookup_order', 'arguments' => ['id' => 1], 'result_id' => 'result-1'], + ]), + 'tool_results' => json_encode([ + ['id' => 'call-1', 'name' => 'lookup_order', 'arguments' => ['id' => 1], 'result' => ['status' => 'shipped'], 'result_id' => 'result-1'], + ]), + 'usage' => '[]', + 'meta' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $messages = $store->getLatestConversationMessages($conversationId, 10); + + expect($messages)->toHaveCount(3) + ->and($messages[0])->toBeInstanceOf(AssistantMessage::class) + ->and($messages[0]->content)->toBe('') + ->and($messages[0]->toolCalls)->toHaveCount(1) + ->and($messages[0]->toolCalls[0]->resultId)->toBe('result-1') + ->and($messages[1])->toBeInstanceOf(ToolResultMessage::class) + ->and($messages[1]->toolResults)->toHaveCount(1) + ->and($messages[1]->toolResults[0]->resultId)->toBe('result-1') + ->and($messages[2])->toBeInstanceOf(AssistantMessage::class) + ->and($messages[2]->content)->toBe('The order has shipped.') + ->and($messages[2]->toolCalls)->toBeEmpty(); }); test('user messages with stored attachments are rehydrated as UserMessage', function () { From 19797e70b0880a0955170fc2281d46dc01482fd4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 30 Jun 2026 23:40:49 +0530 Subject: [PATCH 2/3] Keep final text on tool call message when no tool results stored --- src/Storage/DatabaseConversationStore.php | 20 ++++++++----- .../Storage/DatabaseConversationStoreTest.php | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Storage/DatabaseConversationStore.php b/src/Storage/DatabaseConversationStore.php index 0ed572650..3e5e772ec 100644 --- a/src/Storage/DatabaseConversationStore.php +++ b/src/Storage/DatabaseConversationStore.php @@ -156,20 +156,26 @@ public function getLatestConversationMessages(string $conversationId, int $limit } if ($toolCalls->isNotEmpty()) { + if ($toolResults->isEmpty()) { + return [ + new AssistantMessage( + $record->content, + $toolCalls->map(ToolCall::fromArray(...)), + ), + ]; + } + $messages = [ new AssistantMessage( '', $toolCalls->map(ToolCall::fromArray(...)), ), - ]; - - if ($toolResults->isNotEmpty()) { - $messages[] = new ToolResultMessage( + new ToolResultMessage( $toolResults->map(ToolResult::fromArray(...)), - ); - } + ), + ]; - if ($record->content !== '') { + if (filled($record->content)) { $messages[] = new AssistantMessage($record->content); } diff --git a/tests/Feature/Storage/DatabaseConversationStoreTest.php b/tests/Feature/Storage/DatabaseConversationStoreTest.php index ec1589a78..fd2c0adc8 100644 --- a/tests/Feature/Storage/DatabaseConversationStoreTest.php +++ b/tests/Feature/Storage/DatabaseConversationStoreTest.php @@ -217,6 +217,36 @@ ->and($messages[2]->toolCalls)->toBeEmpty(); }); +test('it keeps the final text on the tool call message when no tool results are stored', function () { + $store = new DatabaseConversationStore; + $conversationId = $store->storeConversation(1, 'Tool conversation'); + + DB::table('agent_conversation_messages')->insert([ + 'id' => 'message-1', + 'conversation_id' => $conversationId, + 'user_id' => 1, + 'agent' => ToolUsingAgent::class, + 'role' => 'assistant', + 'content' => 'The order has shipped.', + 'attachments' => '[]', + 'tool_calls' => json_encode([ + ['id' => 'call-1', 'name' => 'lookup_order', 'arguments' => ['id' => 1], 'result_id' => 'result-1'], + ]), + 'tool_results' => '[]', + 'usage' => '[]', + 'meta' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $messages = $store->getLatestConversationMessages($conversationId, 10); + + expect($messages)->toHaveCount(1) + ->and($messages[0])->toBeInstanceOf(AssistantMessage::class) + ->and($messages[0]->content)->toBe('The order has shipped.') + ->and($messages[0]->toolCalls)->toHaveCount(1); +}); + test('it rehydrates reasoning encrypted content on stored tool calls', function () { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Reasoning conversation'); From 038bf7f82db5e25453674437aad8d7c72dd8a921 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 30 Jun 2026 23:49:40 +0530 Subject: [PATCH 3/3] Drop resultless tool calls when replaying stored conversations --- src/Storage/DatabaseConversationStore.php | 9 ++---- .../Storage/DatabaseConversationStoreTest.php | 31 +++++++++++++++++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/Storage/DatabaseConversationStore.php b/src/Storage/DatabaseConversationStore.php index 3e5e772ec..a52cc4d15 100644 --- a/src/Storage/DatabaseConversationStore.php +++ b/src/Storage/DatabaseConversationStore.php @@ -157,12 +157,9 @@ public function getLatestConversationMessages(string $conversationId, int $limit if ($toolCalls->isNotEmpty()) { if ($toolResults->isEmpty()) { - return [ - new AssistantMessage( - $record->content, - $toolCalls->map(ToolCall::fromArray(...)), - ), - ]; + return filled($record->content) + ? [new AssistantMessage($record->content)] + : []; } $messages = [ diff --git a/tests/Feature/Storage/DatabaseConversationStoreTest.php b/tests/Feature/Storage/DatabaseConversationStoreTest.php index fd2c0adc8..110ca7e00 100644 --- a/tests/Feature/Storage/DatabaseConversationStoreTest.php +++ b/tests/Feature/Storage/DatabaseConversationStoreTest.php @@ -217,7 +217,7 @@ ->and($messages[2]->toolCalls)->toBeEmpty(); }); -test('it keeps the final text on the tool call message when no tool results are stored', function () { +test('it drops resultless tool calls and replays only the final assistant text', function () { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -244,7 +244,34 @@ expect($messages)->toHaveCount(1) ->and($messages[0])->toBeInstanceOf(AssistantMessage::class) ->and($messages[0]->content)->toBe('The order has shipped.') - ->and($messages[0]->toolCalls)->toHaveCount(1); + ->and($messages[0]->toolCalls)->toBeEmpty(); +}); + +test('it drops resultless tool calls with no final text entirely', function () { + $store = new DatabaseConversationStore; + $conversationId = $store->storeConversation(1, 'Tool conversation'); + + DB::table('agent_conversation_messages')->insert([ + 'id' => 'message-1', + 'conversation_id' => $conversationId, + 'user_id' => 1, + 'agent' => ToolUsingAgent::class, + 'role' => 'assistant', + 'content' => '', + 'attachments' => '[]', + 'tool_calls' => json_encode([ + ['id' => 'call-1', 'name' => 'lookup_order', 'arguments' => ['id' => 1], 'result_id' => 'result-1'], + ]), + 'tool_results' => '[]', + 'usage' => '[]', + 'meta' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $messages = $store->getLatestConversationMessages($conversationId, 10); + + expect($messages)->toBeEmpty(); }); test('it rehydrates reasoning encrypted content on stored tool calls', function () {