Skip to content

Commit 53cd842

Browse files
committed
feat(sessions): add ACP session resume and sidebar playback
Implement thread-level ACP session selection and resume across backend providers and the Web UI. Persist agentOptions.sessionId, add thread session listing, bind effective session IDs during turns, and emit session_bound so selected sessions survive across turns. Update prompt construction and chat replay to be session-aware, including session-scoped history filtering, transcript merge fallback behavior, and a paginated right-side session sidebar with switch/new-session actions. This prevents duplicated context after session/load and makes resumed sessions visible and controllable in the UI.
1 parent 0e47efa commit 53cd842

File tree

27 files changed

+2659
-151
lines changed

27 files changed

+2659
-151
lines changed

PROGRESS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,3 +575,32 @@ This file is the source of milestone progress, validation commands, and next act
575575
- 2026-03-09: hid the Web UI Reasoning switch when the active agent exposes fewer than two reasoning choices, so agents without switchable reasoning no longer show a dead control.
576576
- 2026-03-09: switched Kimi model/reasoning catalog queries to local `config.toml` when available, so startup catalog refresh and thread config/model operations no longer create empty Kimi sessions; real prompt turns still use ACP.
577577
- 2026-03-11: added opt-in `--debug` startup flag; when enabled, stderr now emits sanitized `acp.message` traces for ACP stdio and embedded-runtime request/response traffic, including session prompts, updates, and permission flows.
578+
- 2026-03-11: added ACP session browsing/resume support across built-in agents:
579+
- introduced shared agent session abstractions for `session/list`, bound-session reporting, and initialize capability parsing.
580+
- built-in providers now:
581+
- list sessions through ACP `session/list` when supported.
582+
- load persisted `agentOptions.sessionId` through ACP `session/load` before prompting.
583+
- report the effective session id back to HTTP turns so the server can persist it.
584+
- added `GET /v1/threads/{threadId}/sessions` with cursor passthrough and graceful `supported=false` fallback for agents without ACP session-history support.
585+
- turn SSE now emits `session_bound`, and the server persists the thread session id without closing the active provider.
586+
- once a thread is bound to an ACP session, prompt building skips local recent-turn injection to avoid duplicating already-loaded ACP context.
587+
- Web UI now renders a right-side session sidebar with first-page load, `Show more`, active-session highlighting, and `New session` reset.
588+
- executed validation:
589+
- pass: `cd internal/webui/web && npm run build`
590+
- pass: `go test ./internal/httpapi -run 'TestThreadSessionsListEndpoint|TestTurnSessionBoundPersistsSessionIDAndSkipsContextInjection' -count=1`
591+
- pass: `go test ./...`
592+
593+
- 2026-03-11: fixed Web UI session playback when selecting an existing ACP session from the right sidebar.
594+
- the active chat view now treats `(threadId, sessionId)` as its render scope instead of refreshing only on `threadId` changes.
595+
- `loadHistory()` now filters locally persisted turns by each turn's `session_bound` event so the center chat panel replays the selected session's ngent-recorded turns instead of leaving the previous session on screen.
596+
- session changes reported mid-stream by `session_bound` defer the full chat refresh until the active turn completes, so the live streaming bubble is not destroyed.
597+
- executed validation:
598+
- pass: `cd internal/webui/web && npm run build`
599+
- pass: `go test ./...`
600+
601+
- 2026-03-11: fixed Web UI history replay for legacy session threads whose `/history` data lacks per-turn `session_bound` events.
602+
- session-scoped history filtering now falls back to showing all turns when a thread has no annotated session markers at all, instead of rendering an empty chat pane despite non-empty `/history`.
603+
- when a thread has exactly one annotated session, the selected session view also keeps older unannotated turns so pre-annotation history is still visible for that same session.
604+
- executed validation:
605+
- pass: `cd internal/webui/web && npm run build`
606+
- pass: `go test ./...`

cmd/ngent/main.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,14 @@ func main() {
159159
TurnController: turnController,
160160
TurnAgentFactory: func(thread storage.Thread) (agentimpl.Streamer, error) {
161161
modelID := extractModelID(thread.AgentOptionsJSON)
162+
sessionID := extractSessionID(thread.AgentOptionsJSON)
162163
configOverrides := extractConfigOverrides(thread.AgentOptionsJSON)
163164
switch thread.AgentID {
164165
case "codex":
165166
return codexagent.New(codexagent.Config{
166167
Dir: thread.CWD,
167168
ModelID: modelID,
169+
SessionID: sessionID,
168170
ConfigOverrides: configOverrides,
169171
Name: "codex-embedded",
170172
RuntimeConfig: codexRuntimeConfig,
@@ -173,30 +175,35 @@ func main() {
173175
return opencodeagent.New(opencodeagent.Config{
174176
Dir: thread.CWD,
175177
ModelID: modelID,
178+
SessionID: sessionID,
176179
ConfigOverrides: configOverrides,
177180
})
178181
case "gemini":
179182
return geminiagent.New(geminiagent.Config{
180183
Dir: thread.CWD,
181184
ModelID: modelID,
185+
SessionID: sessionID,
182186
ConfigOverrides: configOverrides,
183187
})
184188
case "kimi":
185189
return kimiagent.New(kimiagent.Config{
186190
Dir: thread.CWD,
187191
ModelID: modelID,
192+
SessionID: sessionID,
188193
ConfigOverrides: configOverrides,
189194
})
190195
case "qwen":
191196
return qwenagent.New(qwenagent.Config{
192197
Dir: thread.CWD,
193198
ModelID: modelID,
199+
SessionID: sessionID,
194200
ConfigOverrides: configOverrides,
195201
})
196202
case "claude":
197203
return claudeagent.New(claudeagent.Config{
198204
Dir: thread.CWD,
199205
ModelID: modelID,
206+
SessionID: sessionID,
200207
ConfigOverrides: configOverrides,
201208
Name: "claude-embedded",
202209
})
@@ -672,6 +679,19 @@ func extractModelID(agentOptionsJSON string) string {
672679
return strings.TrimSpace(opts.ModelID)
673680
}
674681

682+
func extractSessionID(agentOptionsJSON string) string {
683+
var opts struct {
684+
SessionID string `json:"sessionId"`
685+
}
686+
if strings.TrimSpace(agentOptionsJSON) == "" {
687+
return ""
688+
}
689+
if err := json.Unmarshal([]byte(agentOptionsJSON), &opts); err != nil {
690+
return ""
691+
}
692+
return strings.TrimSpace(opts.SessionID)
693+
}
694+
675695
func extractConfigOverrides(agentOptionsJSON string) map[string]string {
676696
var opts struct {
677697
ConfigOverrides map[string]any `json:"configOverrides"`

docs/ACCEPTANCE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,25 @@ This checklist defines executable acceptance checks for requirements 1-16.
282282
- `go test ./cmd/ngent -count=1`
283283
- `cd internal/webui/web && npm run build`
284284
- `go test ./...`
285+
286+
## Requirement 23: ACP Session Sidebar and Resume
287+
288+
- Operation:
289+
- create a thread/agent in the Web UI or API.
290+
- query `GET /v1/threads/{threadId}/sessions` and verify the first page of ACP sessions plus `nextCursor`.
291+
- request the next page through the returned cursor.
292+
- start a turn on a thread without `sessionId` and observe session binding.
293+
- start a follow-up turn on the now-bound thread.
294+
- Expected:
295+
- the backend proxies ACP `session/list` through `GET /v1/threads/{threadId}/sessions`.
296+
- response includes `supported`, `sessions`, and `nextCursor`.
297+
- the Web UI renders a right-side session sidebar with:
298+
- first-page load on active thread selection.
299+
- `Show more` pagination when `nextCursor` is present.
300+
- `New session` action that clears the selected `sessionId`.
301+
- turn SSE emits `session_bound`, and the thread persists `agentOptions.sessionId`.
302+
- once a thread is session-bound, subsequent prompt building no longer injects prior local turns into the provider prompt.
303+
- Verification commands (executed 2026-03-11):
304+
- `go test ./internal/httpapi -run 'TestThreadSessionsListEndpoint|TestTurnSessionBoundPersistsSessionIDAndSkipsContextInjection' -count=1`
305+
- `cd internal/webui/web && npm run build`
306+
- `go test ./...`

docs/DECISIONS.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,50 @@ Use this template for new decisions.
714714
- always log ACP payloads at info level (rejected: too noisy and unsafe for normal operation).
715715
- add per-provider bespoke debug flags (rejected: fragmented UX and duplicated plumbing).
716716
- expose raw unredacted ACP dumps (rejected: conflicts with repository logging/redaction requirements).
717+
718+
## ADR-036: Persist thread-level ACP session selection and resume through provider sessions
719+
720+
- Status: Accepted
721+
- Date: 2026-03-11
722+
- Context:
723+
- users need to browse an agent's historical ACP sessions and continue a conversation from an existing provider-owned session.
724+
- hub threads already persist local turn history, but blindly injecting that history into prompts duplicates context once ACP `session/load` has restored the provider's own transcript.
725+
- the frontend needs paginated session discovery and a lightweight way to switch between "new session" and "existing session" without changing the SQLite schema.
726+
- Decision:
727+
- persist the selected ACP session id in `threads.agent_options_json` as `sessionId`.
728+
- expose `GET /v1/threads/{threadId}/sessions` backed by ACP `session/list`, using a fresh provider instance so sidebar discovery does not disturb cached turn runtimes.
729+
- extend built-in providers to:
730+
- load `sessionId` through ACP `session/load` when present.
731+
- create a fresh session through `session/new` otherwise.
732+
- report the effective session id back during turn setup so the server can persist it and emit SSE `session_bound`.
733+
- once `sessionId` is present on a thread, skip local recent-turn prompt injection and rely on ACP session state for continuation.
734+
- keep Web UI session selection as a thread metadata mutation (`PATCH /v1/threads/{threadId}`) and model the "New session" action as clearing `sessionId`.
735+
- Consequences:
736+
- session continuation survives provider restart/server restart when the agent supports ACP `session/load`.
737+
- right-sidebar session browsing stays paginated (`nextCursor`/`Show more`) and does not require schema changes.
738+
- local SQLite history remains a hub-local view and is no longer the source of truth for resumed ACP context on bound threads.
739+
- historical ACP transcript import is deferred and tracked separately as a known limitation.
740+
- Alternatives considered:
741+
- import the full ACP transcript from `session/load` into SQLite immediately (rejected: larger behavioral change, requires reliable transcript reconstruction).
742+
- keep relying on hub prompt injection even after binding to an ACP session (rejected: duplicates already-restored conversation context).
743+
- add a dedicated sessions table instead of reusing `agentOptions` JSON (rejected: unnecessary schema churn for a single thread-scoped selection value).
744+
745+
## ADR-037: Scope Web UI chat playback to the selected ACP session
746+
747+
- Status: Accepted
748+
- Date: 2026-03-11
749+
- Context:
750+
- the Web UI session sidebar switches `agentOptions.sessionId` on the active thread without changing `threadId`.
751+
- local history remains stored per thread, and each turn's effective ACP session is persisted through the `session_bound` event stream.
752+
- refreshing the chat area only on thread changes leaves stale messages visible after choosing a different session from the sidebar.
753+
- Decision:
754+
- treat `(threadId, sessionId)` as the client-side chat render scope.
755+
- when the active thread's selected session changes outside an active turn, rebuild the chat area and reload history for that scope.
756+
- filter locally persisted turns by their `session_bound` event so the center chat panel renders only turns recorded for the selected session; an empty `sessionId` shows only unbound turns.
757+
- Consequences:
758+
- clicking a session in the sidebar replays that session's ngent-recorded turns instead of keeping the previously rendered session on screen.
759+
- session changes reported during a live turn do not wipe the streaming bubble; the full refresh is deferred until the turn finishes.
760+
- transcript content that predates ngent participation is still not imported from the provider and remains covered by KI-021.
761+
- Alternatives considered:
762+
- add a session-scoped history endpoint immediately (rejected: larger server contract change while turn events already contain the session discriminator).
763+
- keep all thread turns visible regardless of selected session (rejected: does not meet the expected session playback behavior).

docs/KNOWN_ISSUES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,12 @@
189189
- Follow-up plan:
190190
- add richer preflight diagnostics for Kimi auth/runtime readiness beyond PATH existence.
191191
- keep validating future Kimi CLI releases and narrow the fallback path once upstream command syntax stabilizes.
192+
193+
- ID: KI-021
194+
- Title: Resumed ACP sessions do not backfill prior transcript into hub history
195+
- Status: Open
196+
- Severity: Medium
197+
- Affects: threads that select an existing ACP `sessionId` from the Web UI/API
198+
- Symptom: after choosing an existing session, ngent resumes context through ACP `session/load`, but the center chat/history panel still shows only turns created through ngent itself; earlier provider-owned transcript is not imported into SQLite or rendered in the chat area.
199+
- Workaround: select the target session before starting new hub turns, and rely on the provider's restored context for continuity even though older messages are not shown locally.
200+
- Follow-up plan: evaluate reconstructing/importing transcript data from `session/load` replayed `session/update` events into local hub history without duplicating future turns.

docs/SPEC.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,59 @@ and upstream ACP schema:
374374
- Limitation:
375375
- this is a compatibility fallback, not full interactive tool-user-input UX.
376376
- multi-question/multi-select semantics and arbitrary free-text answers are not yet exposed through hub APIs/UI.
377+
378+
## 15. ACP Session Sidebar and Resume (2026-03-11)
379+
380+
### 15.1 Thread Metadata
381+
382+
- `threads.agent_options_json` now may contain:
383+
- `modelId`
384+
- `configOverrides`
385+
- `sessionId`
386+
- `sessionId` is optional and represents the selected provider-owned ACP session that the thread should resume.
387+
388+
### 15.2 Backend API
389+
390+
- New endpoint: `GET /v1/threads/{threadId}/sessions`
391+
- ownership/tenancy matches existing thread endpoints.
392+
- query string:
393+
- `cursor` (optional): forwarded to ACP `session/list`.
394+
- response:
395+
- `threadId`
396+
- `supported`
397+
- `sessions`
398+
- `nextCursor`
399+
- Session selection remains a normal thread metadata update:
400+
- `PATCH /v1/threads/{threadId}` with `agentOptions.sessionId="<existing>"` binds an existing ACP session.
401+
- `PATCH /v1/threads/{threadId}` with `agentOptions.sessionId` omitted/empty clears the binding and means "create a new session on next turn".
402+
403+
### 15.3 Provider Behavior
404+
405+
- Built-in providers parse ACP initialize capabilities and distinguish:
406+
- `session/list` availability for sidebar discovery.
407+
- `session/load` availability for actual session resume.
408+
- Turn setup:
409+
- if thread `sessionId` is present, provider calls ACP `session/load`.
410+
- otherwise provider calls ACP `session/new`.
411+
- provider reports the effective session id back through a thread-scoped callback.
412+
- HTTP turn handling persists the bound session id back into `threads.agent_options_json` and emits SSE `session_bound`.
413+
414+
### 15.4 Prompt Construction
415+
416+
- For threads without `sessionId`, prompt construction remains unchanged:
417+
- inject rolling summary + recent visible turns + current user input.
418+
- For threads with `sessionId`, prompt construction returns only the current user input.
419+
- rationale: ACP `session/load` already restored the provider's own transcript, so reinjecting hub-local turns would duplicate context.
420+
421+
### 15.5 Web UI
422+
423+
- Layout:
424+
- left sidebar: thread/agent list.
425+
- center: chat.
426+
- right sidebar: session list for the active thread.
427+
- Session sidebar behavior:
428+
- loads the first page automatically when a thread becomes active.
429+
- shows `Show more` when `nextCursor` is present.
430+
- highlights the currently selected `sessionId`.
431+
- offers `New session` to clear `sessionId`.
432+
- refreshes after turns complete so newly created/bound sessions appear in the list.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/beyond5959/ngent
33
go 1.24
44

55
require (
6-
github.com/beyond5959/acp-adapter v0.1.1-0.20260309073758-eaebd1bcc076
6+
github.com/beyond5959/acp-adapter v0.3.0
77
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
88
modernc.org/sqlite v1.18.2
99
)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
github.com/beyond5959/acp-adapter v0.1.1-0.20260309073758-eaebd1bcc076 h1:0Q7e/2W8gKrOGddyStzalOAtCjB6Qpomu2epFQjdNk8=
2-
github.com/beyond5959/acp-adapter v0.1.1-0.20260309073758-eaebd1bcc076/go.mod h1:cr4I+9+la75oUge+Pr97KlcdQrkwIG6VwWHbc9ALxSE=
1+
github.com/beyond5959/acp-adapter v0.3.0 h1:g9LHARs5jHgtPOP9lEfWQYYlbwE9NHmz4QfU8k6jldo=
2+
github.com/beyond5959/acp-adapter v0.3.0/go.mod h1:cr4I+9+la75oUge+Pr97KlcdQrkwIG6VwWHbc9ALxSE=
33
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
44
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
55
github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo=
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package acpsession
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/beyond5959/ngent/internal/agents"
9+
)
10+
11+
// Capabilities describes ACP session support discovered during initialize.
12+
type Capabilities struct {
13+
CanList bool
14+
CanLoad bool
15+
}
16+
17+
// ParseInitializeCapabilities extracts ACP session capabilities from initialize.
18+
func ParseInitializeCapabilities(raw json.RawMessage) Capabilities {
19+
var payload struct {
20+
AgentCapabilities map[string]json.RawMessage `json:"agentCapabilities"`
21+
}
22+
if len(raw) == 0 || json.Unmarshal(raw, &payload) != nil {
23+
return Capabilities{}
24+
}
25+
26+
caps := Capabilities{}
27+
if enabledFeature(payload.AgentCapabilities["loadSession"]) {
28+
caps.CanLoad = true
29+
}
30+
31+
var sessionCaps map[string]json.RawMessage
32+
if rawSessionCaps, ok := payload.AgentCapabilities["sessionCapabilities"]; ok {
33+
_ = json.Unmarshal(rawSessionCaps, &sessionCaps)
34+
}
35+
if enabledFeature(sessionCaps["list"]) {
36+
caps.CanList = true
37+
}
38+
if enabledFeature(sessionCaps["load"]) || enabledFeature(sessionCaps["resume"]) {
39+
caps.CanLoad = true
40+
}
41+
return caps
42+
}
43+
44+
// ParseSessionListResult decodes one ACP session/list result payload.
45+
func ParseSessionListResult(raw json.RawMessage) (agents.SessionListResult, error) {
46+
var payload struct {
47+
Sessions []struct {
48+
SessionID string `json:"sessionId"`
49+
CWD string `json:"cwd"`
50+
Title string `json:"title"`
51+
UpdatedAt string `json:"updatedAt"`
52+
Meta map[string]any `json:"_meta"`
53+
} `json:"sessions"`
54+
NextCursor string `json:"nextCursor"`
55+
}
56+
if err := json.Unmarshal(raw, &payload); err != nil {
57+
return agents.SessionListResult{}, fmt.Errorf("decode session/list result: %w", err)
58+
}
59+
60+
result := agents.SessionListResult{
61+
NextCursor: strings.TrimSpace(payload.NextCursor),
62+
Sessions: make([]agents.SessionInfo, 0, len(payload.Sessions)),
63+
}
64+
for _, session := range payload.Sessions {
65+
result.Sessions = append(result.Sessions, agents.SessionInfo{
66+
SessionID: session.SessionID,
67+
CWD: session.CWD,
68+
Title: session.Title,
69+
UpdatedAt: session.UpdatedAt,
70+
Meta: session.Meta,
71+
})
72+
}
73+
return agents.CloneSessionListResult(result), nil
74+
}
75+
76+
func enabledFeature(raw json.RawMessage) bool {
77+
raw = json.RawMessage(strings.TrimSpace(string(raw)))
78+
if len(raw) == 0 {
79+
return false
80+
}
81+
switch string(raw) {
82+
case "null", "false", `""`:
83+
return false
84+
}
85+
86+
var boolValue bool
87+
if err := json.Unmarshal(raw, &boolValue); err == nil {
88+
return boolValue
89+
}
90+
return true
91+
}

0 commit comments

Comments
 (0)