You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I have searched the existing issues to avoid creating a duplicate
By submitting this issue, you agree to follow our Code of Conduct
📝 Feature Summary
Include the newest task's state on GET /api/sessions, so clients can tell which sessions are working, waiting for input, or done without reading every conversation.
❓ Problem Statement / Motivation
#1556 ("Sort session history by most recent activity") was closed as completed, motivated by "It can be hard to find active sessions." Sorting took that as far as sorting can go: it tells you which session moved last, not which one is waiting for you. A session that hit input-required an hour ago sorts below one that completed five minutes ago, even though only the first needs a human.
The state exists — it is status.state on the session's newest A2A task — but there is no cheap way to read it per session:
The session row carries no state at all (id / user_id / name / created_at / updated_at / agent_id / source), so GET /api/sessions cannot express it.
Deriving it means GET /api/sessions/{id}/tasksper row, and that endpoint has no projection, no limit and no cursor: it returns every task with its full data blob. A real four-turn session measured ~500 KB. For a list of thirty sessions that is megabytes transferred and parsed to render one badge each.
This affects kagent's own UI: ui/src/components/sidebars/ChatItem.tsx already renders a statusIcon, but only for harness sessions, fed by /api/agentharnesses/{namespace}/{name}/sessions/{session_id}/status. Regular sessions get nothing — the affordance exists and is empty.
It also affects any polling consumer of the REST API. We maintain a Backstage-based portal over kagent and hit exactly this wall: we can show a state badge on the session detail page, where reading the whole conversation is justified anyway, but not in the session list, which is where it would actually help someone find the session that needs them.
💡 Proposed Solution
Add the newest task's state to the objects returned by HandleListSessions, computed in SQL so the cost is one row per session rather than one per task:
SELECT s.*, t.stateAS last_task_state
FROM session s
LEFT JOIN LATERAL (
SELECT (task.data::jsonb ->'status'->>'state') AS state
FROM task
WHEREtask.session_id=s.idANDtask.deleted_at IS NULLORDER BYtask.created_atDESC, task.idDESCLIMIT1
) t ON true
WHEREs.user_id= $1ANDs.deleted_at IS NULL;
Two notes on scope:
Normalise the state vocabulary server-side. Rows persist in two shapes — v1 as TASK_STATE_WORKING, legacy as working — which is why perf(a2a): push ListTasks all-sessions query down to SQL #2302 has to pass status_v1 and status_legacy as separate parameters. Mapping to one vocabulary in Go, via the existing trpcv0 conversion keyed on protocol_version, keeps every client from reimplementing that.
Only the state is missing.session.updated_at is already bumped on every task write by the touched_session CTE in UpsertTask, so "last activity" is covered. This asks for one field, not a summary object.
The ::jsonb extract here is bounded to one row per session, so it avoids the cost that makes the same cast expensive in #2197's all-tasks query.
🔄 Alternatives Considered
tasks/list (feat: serve A2A ListTasks from the task store #2187) with historyLength: 0, includeArtifacts: false and no contextId. One round trip and a small response. But it returns all tasks, not the newest per session, and perf(a2a): push ListTasks all-sessions query down to SQL #2302 paginates ORDER BY task.id with LIMIT/OFFSET. Task ids are opaque, so a page boundary can split a session's tasks arbitrarily, and a caller cannot know it holds the newest task for a given session until it has read every page.
Per-session GET /api/sessions/{id}/tasks. What consumers do today; the cost described above is the motivation for this request.
Denormalise last_task_state onto the session row, written by the touched_session CTE that already updates the session on every task write. That is the cheapest possible read and probably the durable end state, but it needs a numbered migration plus a backfill, and it would have to account for Session ID cannot be recreated after delete: upsert never clears deleted_at, and old events survive #2279 (a soft-deleted session id whose deleted_at is never cleared) so that a recreated id cannot serve a state from its previous life. It presents the same API as the proposal above, so it can replace the LATERAL later without touching a single client.
📋 Prerequisites
📝 Feature Summary
Include the newest task's state on
GET /api/sessions, so clients can tell which sessions are working, waiting for input, or done without reading every conversation.❓ Problem Statement / Motivation
#1556 ("Sort session history by most recent activity") was closed as completed, motivated by "It can be hard to find active sessions." Sorting took that as far as sorting can go: it tells you which session moved last, not which one is waiting for you. A session that hit
input-requiredan hour ago sorts below one that completed five minutes ago, even though only the first needs a human.The state exists — it is
status.stateon the session's newest A2A task — but there is no cheap way to read it per session:id / user_id / name / created_at / updated_at / agent_id / source), soGET /api/sessionscannot express it.GET /api/sessions/{id}/tasksper row, and that endpoint has no projection, no limit and no cursor: it returns every task with its fulldatablob. A real four-turn session measured ~500 KB. For a list of thirty sessions that is megabytes transferred and parsed to render one badge each.This affects kagent's own UI:
ui/src/components/sidebars/ChatItem.tsxalready renders astatusIcon, but only for harness sessions, fed by/api/agentharnesses/{namespace}/{name}/sessions/{session_id}/status. Regular sessions get nothing — the affordance exists and is empty.It also affects any polling consumer of the REST API. We maintain a Backstage-based portal over kagent and hit exactly this wall: we can show a state badge on the session detail page, where reading the whole conversation is justified anyway, but not in the session list, which is where it would actually help someone find the session that needs them.
💡 Proposed Solution
Add the newest task's state to the objects returned by
HandleListSessions, computed in SQL so the cost is one row per session rather than one per task:Two notes on scope:
TASK_STATE_WORKING, legacy asworking— which is why perf(a2a): push ListTasks all-sessions query down to SQL #2302 has to passstatus_v1andstatus_legacyas separate parameters. Mapping to one vocabulary in Go, via the existingtrpcv0conversion keyed onprotocol_version, keeps every client from reimplementing that.session.updated_atis already bumped on every task write by thetouched_sessionCTE inUpsertTask, so "last activity" is covered. This asks for one field, not a summary object.The
::jsonbextract here is bounded to one row per session, so it avoids the cost that makes the same cast expensive in #2197's all-tasks query.🔄 Alternatives Considered
tasks/list(feat: serve A2A ListTasks from the task store #2187) withhistoryLength: 0,includeArtifacts: falseand nocontextId. One round trip and a small response. But it returns all tasks, not the newest per session, and perf(a2a): push ListTasks all-sessions query down to SQL #2302 paginatesORDER BY task.idwithLIMIT/OFFSET. Task ids are opaque, so a page boundary can split a session's tasks arbitrarily, and a caller cannot know it holds the newest task for a given session until it has read every page.GET /api/sessions/{id}/tasks. What consumers do today; the cost described above is the motivation for this request.last_task_stateonto thesessionrow, written by thetouched_sessionCTE that already updates the session on every task write. That is the cheapest possible read and probably the durable end state, but it needs a numbered migration plus a backfill, and it would have to account for Session ID cannot be recreated after delete: upsert never clears deleted_at, and old events survive #2279 (a soft-deleted session id whosedeleted_atis never cleared) so that a recreated id cannot serve a state from its previous life. It presents the same API as the proposal above, so it can replace theLATERALlater without touching a single client.🎯 Affected Service(s)
Controller Service
📚 Additional Context
ListTaskspushdown to SQL; the source of the two-vocabulary state handling and of the discussion about what per-row::jsonbcasts cost.tasks/list, i.e. alternative 1.Postgres is the only supported backend (
go/core/internal/database/client_postgres.go), soLATERALandDISTINCT ONare both available.🙋 Are you willing to contribute?