7.8 KiB
Task Session Turns: Table + Flow
This document describes how to add a turn table that groups all messages from a single agent turn, how messages link to a turn, and how turn updates propagate through the backend → event bus → WebSocket → frontend store.
Goals
- Add a
task_session_turnstable with at leastid,started_at,completed_at. - Ensure every
task_session_messagepoints to the turn it belongs to. - Turn creation/completion follows existing app flow and broadcasts to subscribed clients.
Definitions
- Turn: A single prompt/response cycle for a task session, starting when a prompt is sent and ending when the agent completes/cancels/errors.
- Message: A persisted item in
task_session_messages(user prompt, agent text, tool call, status, error).
Schema Changes (SQLite)
Add a new table and link messages to it.
New Table: task_session_turns
Suggested columns (minimum + useful metadata):
idTEXT PRIMARY KEY (UUID).task_session_idTEXT NOT NULL (FK →task_sessions.id).task_idTEXT NOT NULL (denormalized for query fanout).started_atDATETIME NOT NULL.completed_atDATETIME (nullable).created_atDATETIME NOT NULL.updated_atDATETIME NOT NULL.metadataTEXT DEFAULT '{}' (optional, for provider-specific data like Codexturn_id).
Indexes:
idx_turns_session_idon(task_session_id).idx_turns_session_startedon(task_session_id, started_at).idx_turns_task_idon(task_id).- Optional: partial index for active turn lookup
(task_session_id) WHERE completed_at IS NULL.
Link Messages to Turns
Add a turn_id column to task_session_messages:
turn_idTEXT (FK →task_session_turns.id).- Index on
(turn_id). - Keep nullable for legacy messages; new writes should set it.
Files to update:
apps/backend/internal/task/repository/sqlite.go(schema + migration viaensureColumn).apps/backend/internal/task/models/models.go(addTurnmodel, addTurnIDtoMessage).apps/backend/pkg/api/v1/task.goandapps/web/lib/types/http.ts(API types includeturn_id).
Backend Flow (Turn Lifecycle)
Turns should align with the prompt lifecycle already handled in the orchestrator and message services.
Create Turn
Create a turn when a prompt begins:
- Entry point:
apps/backend/internal/orchestrator/service.goinPromptTaskbefore callingexecutor.Prompt. - Create
task_session_turnsrow withstarted_at = now. - Persist the new
turn_idin memory for the rest of the prompt lifecycle.- Suggested: update
task_sessions.metadata.active_turn_idand/or hold in theExecutor/AgentExecutioncontext.
- Suggested: update
Attach Messages to Turn
Ensure all messages created while a prompt is in-flight use the active turn_id.
Where messages are created today:
- User prompt:
apps/backend/internal/task/handlers/message_handlers.go→MessageController.CreateMessage. - Agent output + tool events:
apps/backend/internal/orchestrator/service.go(saveAgentTextIfPresent,handleToolCallEvent,handleToolUpdateEvent, error/status creation).
Expected behavior:
- The user prompt message should reference the newly created
turn_id. - Subsequent agent/tool/status messages should use the same
turn_id. - If the session is resumed and no active turn exists, create a new turn before message creation.
Implementation sketch:
- Add
TurnIDtoCreateMessageRequest(service/controller layers). - Teach
messageCreator.CreateAgentMessage/CreateToolCallMessage/CreateSessionMessageto accept aturn_idor resolve the current active turn bysession_id. - When the orchestrator receives agent stream events, it should pass
turn_idto message creation calls.
Complete Turn
Close the turn when the agent finishes or the turn is cancelled:
- Completion:
apps/backend/internal/orchestrator/service.goinhandleAgentStreamEventwheneventType == "complete". - Cancellation:
apps/backend/internal/orchestrator/service.goinCancelAgent. - Errors: Treat
eventType == "error"as terminal if it ends the prompt; setcompleted_at.
Action:
- Update
task_session_turns.completed_at = now,updated_at = now. - Clear
task_sessions.metadata.active_turn_id(if used).
Event Bus and WebSocket Broadcast
Turn updates should follow the same path as existing message/session events.
Event Types
Add new event types in apps/backend/internal/events/types.go:
TurnStarted = "turn.started"(ortask_session.turn.started).TurnCompleted = "turn.completed".- Optional
TurnUpdated = "turn.updated"for partial updates.
Publish Events
Publish turn events when you create/complete a turn:
- Location: task service or orchestrator service (same layer that writes to DB).
- Payload fields (minimum):
turn_idsession_idtask_idstarted_atcompleted_at
Broadcast to WebSocket Subscribers
Wire up in apps/backend/internal/gateway/websocket/task_notifications.go:
- Subscribe to the new event types.
- Broadcast to the session channel (same as
session.message.added).
Add action constants in apps/backend/pkg/websocket/actions.go:
ActionSessionTurnStarted = "session.turn.started"ActionSessionTurnCompleted = "session.turn.completed"- Optional
ActionSessionTurnUpdated = "session.turn.updated"
Frontend Flow
Frontend store and handlers should mirror the current message flow.
WebSocket Handlers
Add a new WS handler file (or extend existing):
apps/web/lib/ws/handlers/turns.tsto handle:session.turn.startedsession.turn.completed
- Store updates should be keyed by
session_id.
Store Shape
Add a turns slice in apps/web/lib/state/store.ts:
turns.bySession[sessionId] = Turn[]turns.activeBySession[sessionId] = Turn | null(optional for UI)
Message Rendering
Messages already render from messages.bySession[sessionId]. With turn_id:
- Allow grouping in UI by turn.
- Allow filtering by active turn.
- Show turn duration in the UI (e.g., "running for 42s") by comparing
started_atto now for active turns.
SSR Data
If needed for initial render:
- Add an HTTP endpoint to list turns for a session.
- SSR fetch in
apps/web/app/task/[id]/page-client.tsxor equivalent (seedocs/session-page-data-flow.md).
Migration / Backfill
Existing messages will not have turn_id:
- Keep
turn_idnullable. - Optional backfill: create one turn per session using min/max message timestamps and assign messages with NULL
turn_idto that turn.
Edge Cases
- Multiple prompts queued: Ensure only one active turn per session. If prompts can overlap, add a
statecolumn or queue ID. - Agent error before messages: Start turn on prompt, complete on error even if no agent messages.
- Permission requests: If the agent pauses for permission, the turn remains open; completion happens after prompt completion or cancellation.
- Concurrency: Multiple sessions can run in parallel without conflict as long as turns and broadcasts stay scoped by
session_id(andactive_turn_idis per session).
Touchpoints Summary
Backend:
apps/backend/internal/task/models/models.go(Turn model, Message.TurnID)apps/backend/internal/task/repository/sqlite.go(schema + queries)apps/backend/internal/task/repository/interface.go(turn methods)apps/backend/internal/task/service/service.go(turn create/complete, message association)apps/backend/internal/orchestrator/service.go(prompt start/complete)apps/backend/internal/events/types.go(turn events)apps/backend/internal/gateway/websocket/task_notifications.go(broadcast)apps/backend/pkg/websocket/actions.go(actions)
Frontend:
apps/web/lib/ws/handlers/turns.ts(WS handlers)apps/web/lib/state/store.ts(turns slice)apps/web/lib/types/http.ts(Turn + Message.turn_id)apps/web/components/task/chat/*(optional turn grouping UI)
Docs:
docs/WEBSOCKET_API.md(document new turn actions and payloads).