feat(session): persist handoff notes on end

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 04:03:12 +00:00
parent 6cb5a9f39a
commit ff24898cd4
6 changed files with 77 additions and 7 deletions

View file

@ -16,10 +16,11 @@ use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* End an agent session with a final status and optional summary.
* End an agent session with a final status, summary, and optional handoff notes.
*
* Usage:
* $session = EndSession::run('ses_abc123', 'completed', 'All phases done');
* $session = EndSession::run('ses_abc123', 'handed_off', 'Ready for review', ['summary' => 'Ready for review']);
*/
class EndSession
{
@ -32,7 +33,12 @@ class EndSession
/**
* @throws \InvalidArgumentException
*/
public function handle(string $sessionId, string $status, ?string $summary = null): AgentSession
public function handle(
string $sessionId,
string $status,
?string $summary = null,
?array $handoffNotes = null,
): AgentSession
{
if ($sessionId === '') {
throw new \InvalidArgumentException('session_id is required');
@ -45,7 +51,7 @@ class EndSession
);
}
$session = $this->sessionService->end($sessionId, $status, $summary);
$session = $this->sessionService->end($sessionId, $status, $summary, $handoffNotes);
if (! $session) {
throw new \InvalidArgumentException("Session not found: {$sessionId}");

View file

@ -122,16 +122,24 @@ class SessionController extends Controller
$validated = $request->validate([
'status' => 'required|string|in:completed,handed_off,paused,failed',
'summary' => 'nullable|string|max:10000',
'handoff_notes' => 'nullable|array',
]);
try {
$session = EndSession::run($id, $validated['status'], $validated['summary'] ?? null);
$session = EndSession::run(
$id,
$validated['status'],
$validated['summary'] ?? null,
$validated['handoff_notes'] ?? null,
);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
'final_summary' => $session->final_summary,
'handoff_notes' => $session->handoff_notes,
],
]);
} catch (\InvalidArgumentException $e) {

View file

@ -40,6 +40,10 @@ class SessionEnd extends AgentTool
'type' => 'string',
'description' => 'Final summary',
],
'handoff_notes' => [
'type' => 'object',
'description' => 'Optional handoff details for the next agent',
],
],
'required' => ['status'],
];
@ -57,6 +61,7 @@ class SessionEnd extends AgentTool
$sessionId,
$args['status'] ?? '',
$args['summary'] ?? null,
$args['handoff_notes'] ?? null,
);
return $this->success([

View file

@ -243,7 +243,7 @@ class AgentSession extends Model
/**
* End the session with a status.
*/
public function end(string $status, ?string $summary = null): self
public function end(string $status, ?string $summary = null, ?array $handoffNotes = null): self
{
$validStatuses = [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_HANDED_OFF];
@ -254,6 +254,7 @@ class AgentSession extends Model
$this->update([
'status' => $status,
'final_summary' => $summary,
'handoff_notes' => $handoffNotes ?? $this->handoff_notes,
'ended_at' => now(),
]);

View file

@ -125,7 +125,12 @@ class AgentSessionService
/**
* End a session.
*/
public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession
public function end(
string $sessionId,
string $status = AgentSession::STATUS_COMPLETED,
?string $summary = null,
?array $handoffNotes = null
): ?AgentSession
{
$session = $this->get($sessionId);
@ -133,7 +138,7 @@ class AgentSessionService
return null;
}
$session->end($status, $summary);
$session->end($status, $summary, $handoffNotes);
// Remove from active cache
$this->clearCachedSession($session);

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\Api\SessionController;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Request;
it('ends a session with handoff notes', function (): void {
$workspace = Workspace::factory()->create();
$session = AgentSession::factory()->active()->create([
'workspace_id' => $workspace->id,
]);
$request = Request::create('/v1/sessions/'.$session->session_id.'/end', 'POST', [
'status' => 'handed_off',
'summary' => 'Ready for review',
'handoff_notes' => [
'summary' => 'Ready for review',
'next_steps' => ['Run the verifier'],
'blockers' => ['Need approval'],
'context_for_next' => ['repo' => 'core/go-io'],
],
]);
$request->attributes->set('workspace', $workspace);
$response = app(SessionController::class)->end($request, $session->session_id);
expect($response->getStatusCode())->toBe(200);
$payload = $response->getData(true);
expect($payload['data']['session_id'])->toBe($session->session_id)
->and($payload['data']['status'])->toBe(AgentSession::STATUS_HANDED_OFF)
->and($payload['data']['final_summary'])->toBe('Ready for review')
->and($payload['data']['handoff_notes']['summary'])->toBe('Ready for review')
->and($payload['data']['handoff_notes']['next_steps'])->toBe(['Run the verifier']);
$fresh = $session->fresh();
expect($fresh?->status)->toBe(AgentSession::STATUS_HANDED_OFF)
->and($fresh?->final_summary)->toBe('Ready for review')
->and($fresh?->handoff_notes['context_for_next'])->toBe(['repo' => 'core/go-io']);
});