Skip to content

Commit a578e29

Browse files
authored
Merge pull request #17 from beyond5959/dev
feat: surface ACP plans and use local Kimi config
2 parents d310470 + 5ec8284 commit a578e29

File tree

27 files changed

+1399
-144
lines changed

27 files changed

+1399
-144
lines changed

.goreleaser.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ builds:
3131

3232
archives:
3333
- id: binary
34-
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ if eq .Arch \"arm64\" }}aarch64{{ else if eq .Arch \"amd64\" }}x86_64{{ else }}{{ .Arch }}{{ end }}-{{ .Os }}"
34+
name_template: "{{ .ProjectName }}-{{ .Tag }}-{{ if eq .Arch \"arm64\" }}aarch64{{ else if eq .Arch \"amd64\" }}x86_64{{ else }}{{ .Arch }}{{ end }}-{{ .Os }}"
3535
format: tar.gz
3636
format_overrides:
3737
- goos: windows
@@ -42,11 +42,11 @@ archives:
4242

4343
source:
4444
enabled: true
45-
name_template: "{{ .ProjectName }}-{{ .Version }}-source"
45+
name_template: "{{ .ProjectName }}-{{ .Tag }}-source"
4646
format: tar.gz
4747

4848
checksum:
49-
name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt"
49+
name_template: "{{ .ProjectName }}-{{ .Tag }}-checksums.txt"
5050

5151
changelog:
5252
sort: asc

PROGRESS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,21 @@ This file is the source of milestone progress, validation commands, and next act
556556
- pass: `cd internal/webui/web && npm run build`
557557
- pass: `go test ./...`
558558

559+
- `Post-F9` ACP plan streaming in Web UI completed:
560+
- added shared ACP `session/update` parsing for both `agent_message_chunk` and `plan`, with plan routed through a new per-turn `PlanHandler` context callback.
561+
- introduced SSE/history event `plan_update` so ACP plans are persisted alongside other turn events instead of being dropped at provider boundaries.
562+
- updated the Web UI to render live plan cards during streaming and restore the latest plan from turn history on reload.
563+
- executed validation:
564+
- pass: `cd internal/webui/web && npm run build`
565+
- pass: `go test ./...`
566+
559567

560568
- 2026-03-06: Removed the thread action trigger's per-thread actionLabel/title tooltip in the Web UI popover menu; the three-dot button now uses a neutral `aria-label` only, without hover text tied to the thread title.
561569
- 2026-03-06: Moved the sidebar thread action menu and rename form into a sidebar-level floating layer instead of rendering them inside each thread row, so the rename UI is no longer clipped by the thread list or sidebar overflow.
562570
- 2026-03-06: fixed embedded codex permission bridge timeout mismatch by aligning adapter-side `session/request_permission` wait window to 2h (was 30s), matching hub default timeout and avoiding premature fail-closed during manual approval.
563571
- 2026-03-06: fixed embedded codex server-request compatibility for tool interaction:
564572
- `item/tool/requestUserInput` now returns schema-compatible answers (auto-select first option label per question) instead of `-32000 not supported`.
565573
- `item/tool/call` now returns structured tool failure payload (`success=false`) instead of JSON-RPC method error, so app-server no longer aborts the whole flow on this request type.
574+
- 2026-03-09: unified ACP message-chunk constant usage across stdio providers by removing per-provider `updateTypeMessageChunk` definitions and reusing `agents.ACPUpdateTypeMessageChunk`.
575+
- 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.
576+
- 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.

docs/ACCEPTANCE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ This checklist defines executable acceptance checks for requirements 1-16.
101101
## Requirement 13: Embedded Web UI
102102

103103
- Operation: start server; open browser at `http://127.0.0.1:8686/`.
104-
- Expected: UI loads, threads can be created, turns stream in real time, permissions can be resolved, history is browsable.
104+
- Expected: UI loads, threads can be created, turns stream in real time, ACP plan updates render as a live plan card, permissions can be resolved, and history is browsable.
105105
- Verification command:
106106
- `go test ./internal/webui -count=1` (checks `GET /` returns 200 with `text/html` content-type and SPA fallback)
107+
- `cd internal/webui/web && npm run build`
107108
- manual: `make run` → open `http://127.0.0.1:8686/` or scan the startup QR code from another device
108109

109110
## Global Gate
@@ -112,6 +113,7 @@ This checklist defines executable acceptance checks for requirements 1-16.
112113
- Expected: formatting and tests are green.
113114
- Verification command:
114115
- `gofmt -w $(find . -name '*.go' -type f)`
116+
- `cd internal/webui/web && npm run build`
115117
- `go test ./...`
116118

117119
## Requirement 14: OpenCode Agent
@@ -152,8 +154,10 @@ This checklist defines executable acceptance checks for requirements 1-16.
152154
- thread creation accepts `agent=kimi`.
153155
- turn streaming emits `message_delta` and finishes with `turn_completed` (or explicit upstream error envelope).
154156
- provider tolerates current upstream ACP startup variants `kimi acp` and `kimi --acp`.
157+
- thread config/model discovery avoids creating extra empty Kimi sessions when local Kimi config is available.
155158
- Verification commands:
156159
- `go test ./internal/agents/kimi -count=1`
160+
- `E2E_KIMI=1 go test ./internal/agents/kimi -run TestKimiConfigOptionsE2EDoesNotCreateSession -v -timeout 120s`
157161
- `E2E_KIMI=1 go test ./internal/agents/kimi -run TestKimiE2ESmoke -v -timeout 120s`
158162
- `go test ./cmd/ngent ./internal/httpapi -count=1`
159163

docs/API.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,11 @@ All errors use:
252252
- SSE event types:
253253
- `turn_started`: `{"turnId":"..."}`
254254
- `message_delta`: `{"turnId":"...","delta":"..."}`
255+
- `plan_update`: `{"turnId":"...","entries":[{"content":"...","status":"pending|in_progress|completed","priority":"low|medium|high"}]}`
255256
- `permission_required`: `{"turnId":"...","permissionId":"...","approval":"command|file|network|mcp","command":"...","requestId":"..."}`
256257
- `turn_completed`: `{"turnId":"...","stopReason":"end_turn|cancelled|error"}`
257258
- `error`: `{"turnId":"...","code":"...","message":"..."}`
259+
- for ACP `sessionUpdate == "plan"`, the server emits `plan_update` and treats each payload as a full replacement of the current plan list.
258260

259261
- Permission fail-closed contract:
260262
- permission request timeout or disconnected stream defaults to `declined`.

docs/DECISIONS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
- ADR-030: Pin local acp-adapter hotfix for codex app-server server-request compatibility. (Accepted)
3535
- ADR-031: Kimi CLI ACP stdio provider with dual startup syntax fallback. (Accepted)
3636
- ADR-032: Shared common agent config/state helper without protocol unification. (Accepted)
37+
- ADR-033: Surface ACP plan updates as first-class SSE and Web UI state. (Accepted)
38+
- ADR-034: Source Kimi config catalogs from local config to avoid empty sessions. (Accepted)
3739

3840
## ADR-018: Embedded Web UI via Go embed
3941

@@ -662,3 +664,44 @@ Use this template for new decisions.
662664
- Alternatives considered:
663665
- create one generic ACP provider with pluggable commands and hooks (rejected: protocol differences are too large and already visible across current providers).
664666
- leave duplicated `Config`/`Client` state in place (rejected: ongoing maintenance cost and drift risk).
667+
668+
## ADR-033: Surface ACP plan updates as first-class SSE and Web UI state
669+
670+
- Status: Accepted
671+
- Date: 2026-03-09
672+
- Context:
673+
- ACP agents can emit `session/update` notifications with `sessionUpdate == "plan"` and a full `entries[]` list describing the current execution plan.
674+
- the hub previously only mapped `agent_message_chunk` into `message_delta`, so users could not see plan progress in the Web UI and history reloads lost that context entirely.
675+
- Decision:
676+
- normalize ACP `session/update` payloads in shared agent code, recognizing both `agent_message_chunk` and `plan`.
677+
- route plan replacements through a new per-turn `PlanHandler` context callback, parallel to the existing permission callback pattern.
678+
- emit and persist a dedicated SSE/history event `plan_update` with payload `{"turnId","entries":[]}`.
679+
- render `plan_update` in the Web UI as a live plan card above the streaming agent bubble, and rebuild the final plan state from persisted turn events when loading history.
680+
- Consequences:
681+
- ACP plan state is now visible during live execution without overloading `message_delta`.
682+
- history replay preserves the last known plan instead of dropping it on refresh.
683+
- empty `entries[]` remains meaningful as "clear the current plan", so the hub must preserve replacement semantics instead of merging incrementally.
684+
- Alternatives considered:
685+
- fold plan text into `message_delta` (rejected: mixes distinct ACP concepts and loses replacement semantics).
686+
- keep plan rendering purely transient in the browser (rejected: history reload would still discard plan state).
687+
688+
## ADR-034: Source Kimi config catalogs from local config to avoid empty sessions
689+
690+
- Status: Accepted
691+
- Date: 2026-03-09
692+
- Context:
693+
- Kimi CLI persists ACP `session/new` handshakes as local session history, even when the hub only wants model/config metadata and never sends a prompt.
694+
- the previous Kimi provider queried models and thread config options through `session/new`, which polluted `~/.kimi/sessions` with empty sessions during startup catalog refresh, thread config loads, and model changes.
695+
- local Kimi config already exposes the needed metadata: `default_model`, `default_thinking`, and model capabilities.
696+
- Decision:
697+
- read Kimi model catalog and default thinking state from local `config.toml` when available.
698+
- synthesize thread `ConfigOptions` and `DiscoverModels()` results from that local config instead of ACP `session/new`.
699+
- apply reasoning overrides for real prompt turns through Kimi startup flags (`--thinking` / `--no-thinking`) and keep model selection on `--model`.
700+
- retain ACP fallback only when local config cannot be read or parsed.
701+
- Consequences:
702+
- Kimi thread config queries and startup catalog refresh no longer create empty session history entries in normal local setups.
703+
- Kimi keeps real ACP turns for actual prompts, permissions, streaming, and cancellation behavior.
704+
- local config structure becomes part of the provider compatibility surface, so future Kimi config schema drift must be monitored.
705+
- Alternatives considered:
706+
- keep ACP-only config discovery (rejected: side effect creates noisy empty sessions).
707+
- disable Kimi config/model catalog refresh entirely (rejected: would regress model picker accuracy).

docs/SPEC.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ and upstream ACP schema:
149149
- `session/update` notifications:
150150
- stream deltas from `update.sessionUpdate == "agent_message_chunk"`
151151
- consume text from `update.content.text` only.
152+
- map `update.sessionUpdate == "plan"` into hub `plan_update` SSE events; each `entries[]` payload replaces the current plan list.
152153
- permission request/response:
153154
- handle `session/request_permission` request from provider.
154155
- reply with ACP outcome shape:
@@ -235,13 +236,18 @@ and upstream ACP schema:
235236
- startup command:
236237
- official Kimi docs currently show both `kimi acp` and `kimi --acp`.
237238
- the hub must try `kimi acp` first and fall back to `kimi --acp` when ACP initialization closes immediately.
239+
- local config sourcing:
240+
- model catalog and default thinking state should be read from local Kimi config (`config.toml`) when available.
241+
- avoid creating ACP `session/new` calls for model discovery or thread config queries that do not send a real prompt, to prevent empty Kimi sessions.
242+
- if local config is unavailable or cannot be parsed, fall back to ACP handshake-based discovery/query behavior.
238243
- ACP flow:
239244
- `initialize` with `protocolVersion: 1` and `clientCapabilities.fs`.
240245
- `session/new` with `cwd` and `mcpServers: []`.
241246
- `session/prompt` with ACP prompt blocks (`[{type:"text", text:...}]`).
242247
- streaming:
243248
- consume `session/update` deltas only when `update.sessionUpdate == "agent_message_chunk"`.
244249
- delta text is read from `update.content.text`.
250+
- map `update.sessionUpdate == "plan"` into hub `plan_update` SSE events; each `entries[]` payload replaces the current plan list.
245251
- permissions and cancellation:
246252
- handle `session/request_permission` with fail-closed approval mapping.
247253
- on context cancellation, send `session/cancel` quickly and converge to `stopReason=cancelled`.
@@ -326,6 +332,7 @@ and upstream ACP schema:
326332
- frontend cache is keyed by `agent + selected model`, so same-agent threads can reuse the same model-specific catalog without incorrectly sharing another model's reasoning list.
327333
- Option descriptions are rendered inside the dropdown menus for selectable values.
328334
- During streaming or in-flight switch request, both controls are disabled to preserve turn/config safety.
335+
- ACP `plan_update` events render as a live plan card above the active agent bubble, and the latest persisted plan is restored when reloading thread history.
329336
- Sidebar thread actions now live behind a thread-row drawer:
330337
- trigger stays in the row action area.
331338
- drawer contains inline `Rename` and `Delete` actions.

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.0.0-20260306094737-51c98ca346b3
6+
github.com/beyond5959/acp-adapter v0.1.1-0.20260309073758-eaebd1bcc076
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.0.0-20260306094737-51c98ca346b3 h1:GG5MUaGHsO3onQp5o0vjSilXDKFR79GE7kBZKqlynn4=
2-
github.com/beyond5959/acp-adapter v0.0.0-20260306094737-51c98ca346b3/go.mod h1:cr4I+9+la75oUge+Pr97KlcdQrkwIG6VwWHbc9ALxSE=
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=
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=

internal/agents/acp/acp.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,22 @@ func (c *Client) Stream(ctx context.Context, input string, onDelta func(delta st
152152
if msg.Method != "session/update" {
153153
return nil
154154
}
155-
var payload struct {
156-
Delta string `json:"delta"`
157-
}
158155
if len(msg.Params) == 0 {
159156
return nil
160157
}
161-
if err := json.Unmarshal(msg.Params, &payload); err != nil {
162-
return fmt.Errorf("acp: decode session/update: %w", err)
158+
update, err := agents.ParseACPUpdate(msg.Params)
159+
if err != nil {
160+
return fmt.Errorf("acp: %w", err)
163161
}
164-
return onDelta(payload.Delta)
162+
switch update.Type {
163+
case agents.ACPUpdateTypeMessageChunk:
164+
return onDelta(update.Delta)
165+
case agents.ACPUpdateTypePlan:
166+
if handler, ok := agents.PlanHandlerFromContext(ctx); ok {
167+
return handler(ctx, update.PlanEntries)
168+
}
169+
}
170+
return nil
165171
})
166172

167173
conn.SetRequestHandler(func(msg rpcMessage) error {

internal/agents/acp_update.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package agents
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
const (
11+
// ACPUpdateTypeMessageChunk streams agent text deltas.
12+
ACPUpdateTypeMessageChunk = "agent_message_chunk"
13+
// ACPUpdateTypePlan replaces the current agent plan entries.
14+
ACPUpdateTypePlan = "plan"
15+
)
16+
17+
// PlanEntry is one ACP plan entry shown to the user.
18+
type PlanEntry struct {
19+
Content string `json:"content"`
20+
Status string `json:"status,omitempty"`
21+
Priority string `json:"priority,omitempty"`
22+
}
23+
24+
// ACPUpdate is one normalized ACP session/update payload.
25+
type ACPUpdate struct {
26+
Type string
27+
Delta string
28+
PlanEntries []PlanEntry
29+
}
30+
31+
// ParseACPUpdate normalizes provider-specific session/update payloads.
32+
func ParseACPUpdate(raw json.RawMessage) (ACPUpdate, error) {
33+
if len(raw) == 0 {
34+
return ACPUpdate{}, nil
35+
}
36+
37+
var payload struct {
38+
Delta string `json:"delta"`
39+
Update struct {
40+
SessionUpdate string `json:"sessionUpdate"`
41+
Content struct {
42+
Type string `json:"type"`
43+
Text string `json:"text"`
44+
} `json:"content"`
45+
Entries []PlanEntry `json:"entries"`
46+
} `json:"update"`
47+
}
48+
if err := json.Unmarshal(raw, &payload); err != nil {
49+
return ACPUpdate{}, fmt.Errorf("decode ACP session/update payload: %w", err)
50+
}
51+
52+
switch strings.TrimSpace(payload.Update.SessionUpdate) {
53+
case "":
54+
if payload.Delta == "" {
55+
return ACPUpdate{}, nil
56+
}
57+
return ACPUpdate{
58+
Type: ACPUpdateTypeMessageChunk,
59+
Delta: payload.Delta,
60+
}, nil
61+
case ACPUpdateTypeMessageChunk:
62+
if contentType := strings.TrimSpace(payload.Update.Content.Type); contentType != "" && contentType != "text" {
63+
return ACPUpdate{Type: ACPUpdateTypeMessageChunk}, nil
64+
}
65+
return ACPUpdate{
66+
Type: ACPUpdateTypeMessageChunk,
67+
Delta: payload.Update.Content.Text,
68+
}, nil
69+
case ACPUpdateTypePlan:
70+
return ACPUpdate{
71+
Type: ACPUpdateTypePlan,
72+
PlanEntries: normalizePlanEntries(payload.Update.Entries),
73+
}, nil
74+
default:
75+
return ACPUpdate{Type: strings.TrimSpace(payload.Update.SessionUpdate)}, nil
76+
}
77+
}
78+
79+
// ClonePlanEntries returns a trimmed deep copy of the provided entries.
80+
func ClonePlanEntries(entries []PlanEntry) []PlanEntry {
81+
return normalizePlanEntries(entries)
82+
}
83+
84+
func normalizePlanEntries(entries []PlanEntry) []PlanEntry {
85+
if len(entries) == 0 {
86+
return nil
87+
}
88+
89+
normalized := make([]PlanEntry, 0, len(entries))
90+
for _, entry := range entries {
91+
content := strings.TrimSpace(entry.Content)
92+
if content == "" {
93+
continue
94+
}
95+
normalized = append(normalized, PlanEntry{
96+
Content: content,
97+
Status: strings.TrimSpace(entry.Status),
98+
Priority: strings.TrimSpace(entry.Priority),
99+
})
100+
}
101+
if len(normalized) == 0 {
102+
return nil
103+
}
104+
return normalized
105+
}
106+
107+
// PlanHandler receives ACP plan replacements for the active turn.
108+
type PlanHandler func(ctx context.Context, entries []PlanEntry) error
109+
110+
type planHandlerContextKey struct{}
111+
112+
// WithPlanHandler binds one per-turn plan callback to context.
113+
func WithPlanHandler(ctx context.Context, handler PlanHandler) context.Context {
114+
if handler == nil {
115+
return ctx
116+
}
117+
return context.WithValue(ctx, planHandlerContextKey{}, handler)
118+
}
119+
120+
// PlanHandlerFromContext gets plan callback from context, if present.
121+
func PlanHandlerFromContext(ctx context.Context) (PlanHandler, bool) {
122+
if ctx == nil {
123+
return nil, false
124+
}
125+
handler, ok := ctx.Value(planHandlerContextKey{}).(PlanHandler)
126+
if !ok || handler == nil {
127+
return nil, false
128+
}
129+
return handler, true
130+
}

0 commit comments

Comments
 (0)