Skip to content

[FEATURE] Surface each session's current task state in the session list #2397

Description

@marians

📋 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-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}/tasks per 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.state AS last_task_state
FROM session s
LEFT JOIN LATERAL (
  SELECT (task.data::jsonb -> 'status' ->> 'state') AS state
  FROM task
  WHERE task.session_id = s.id AND task.deleted_at IS NULL
  ORDER BY task.created_at DESC, task.id DESC
  LIMIT 1
) t ON true
WHERE s.user_id = $1 AND s.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

  1. 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.
  2. Per-session GET /api/sessions/{id}/tasks. What consumers do today; the cost described above is the motivation for this request.
  3. 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.

🎯 Affected Service(s)

Controller Service

📚 Additional Context

Postgres is the only supported backend (go/core/internal/database/client_postgres.go), so LATERAL and DISTINCT ON are both available.

🙋 Are you willing to contribute?

  • I am willing to submit a PR for this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions