Skip to content

Commit 3c554dd

Browse files
author
zengfr
committed
feat(acp): P6 session/newTask + notifications/session/update (ADR-0020)
ACP v1 protocol now complete enough to drive the orchestrator: external clients can call session/newTask to dispatch a user request to a wired Director and receive streamed events as JSON-RPC notifications. - DirectorLike interface + NewWithDirector constructor; Server now carries an optional director + notifier (no import cycle). - session/newTask: creates a Task under the session, fires Handle in a goroutine, returns taskId synchronously. Events (start, message, tool_call, done|error) are pushed via the notifier as notifications/session/update (no JSON-RPC id). - Task + TaskEvent types; Session tracks per-task events in memory. - TestACP_SessionNewTask_StreamsEvents drives ServeReader over an io.Pipe pair, asserts session/newTask returns taskId and that the notifier receives >=4 notifications/session/update events. - ADR-0020 + docs/plan/p6-acp-session-newtask.md.
1 parent 8380b82 commit 3c554dd

5 files changed

Lines changed: 423 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- P6: ACP v1 session/newTask method dispatching to Director in a goroutine
12+
- ACP Server NewWithDirector constructor + DirectorLike interface
13+
- notifications/session/update JSON-RPC push (no ID) for streamed TaskEvents (start/message/tool_call/done/error)
14+
- Task + TaskEvent types for in-memory event history
15+
- TestACP_SessionNewTask_StreamsEvents e2e over in-process stdio pipe
16+
17+
### Changed
18+
- ACP Session struct now tracks active tasks per session
19+
20+
## [0.7.0] - 2026-09-06
21+
22+
### Added
23+
- P6: ACP session/newTask + notifications/session/update (ADR-0020)
24+
- DirectorLike abstraction; NewWithDirector constructor
25+
- Task + TaskEvent types; TestACP_SessionNewTask_StreamsEvents
26+
27+
### Changed
28+
- ACP Session struct now tracks active tasks per session
29+
30+
1031
### Added
1132
- P4: codex + opencode driver stdout streaming (StdoutPipe + bufio.Scanner, ADR-0019)
1233
- testdata/stubbin/codex-stream multi-line stub for streaming tests
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ADR-0020:P6 — ACP v1 `session/newTask` 与流式事件
2+
3+
> 状态:Accepted
4+
> 日期:2026-09-06
5+
> 关联:ADR-0014(ACP/MCP 真实实现)、ADR-0019(P4 流式)
6+
> 计划:`docs/plan/p6-acp-session-newtask.md`
7+
8+
## 背景
9+
10+
ACP server 实现了 `initialize`/`session/start`/`session/stop`/`session/list`,但缺 `session/newTask`——ACP v1 的核心方法。外部客户端(IDE 插件等)无法向 session 发任务并拿流式事件。同时 Server 未接 Director,无法把任务交给编排引擎。
11+
12+
## 决策
13+
14+
1. 实现 `session/newTask` method:接收 `agentId`/`prompt`,返回 task ID。
15+
2. `session/newTask` 调 `Director.Handle`(同步 goroutine 中),把 delivery 的 ProgressEvent 转成 ACP 事件(start/message/tool_call/done/error)。
16+
3. 通过 JSON-RPC `notifications/session/update`(无 ID)推送流式事件,事件含 `sessionId`+`taskId` 字段以供客户端关联。
17+
4. `NewWithDirector` 构造器让 ACP server 指向 Director。
18+
19+
## 备选方案
20+
21+
- 同步调 Handle 再发事件:事件流要等整个 delivery 完成才开始,不是流式,否决。
22+
- 只发 done 不发中间事件:外部客户端体验差,否决。
23+
24+
## 后果
25+
26+
- 正面:外部 ACP 客户端可驱动编排引擎并拿流式进度;ACP 协议覆盖度提升。
27+
- 负面:JSON-RPC notification 无 ID,客户端需靠 sessionId+taskId 关联(已在事件中带)。
28+
- 中性:现有 initialize/start/stop/list 不变。
29+
30+
## 验收
31+
32+
- `TestACP_SessionNewTask_StreamsEvents` 跑通
33+
- 现有 ACP 测试无回归
34+
- `go build ./...` + `go test ./...` 全绿
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# P6 — ACP v1 stdio JSON-RPC 完整实现
2+
3+
> 决策依据:`docs/adr/ADR-0020-p6-acp-session-newtask.md`
4+
> 目标版本:v0.7.0
5+
> 前置依赖:P4(v0.6.0,已落地)
6+
> 后续:P5(OpenCode serve HTTP API)
7+
8+
## 0. 范围
9+
10+
**做**:
11+
1. 在 `internal/acp` 实现 `session/newTask` method:接收 `agentId`/`prompt`,返回 task ID。
12+
2. 在 session 运行时,通过 JSON-RPC `notifications/session/update` 推送流式事件(start/message/tool_call/done/error)。
13+
3. 把 ACP server 接到 Director:`session/newTask` 调 `Director.Handle` 或快编路径,把 delivery 的 ProgressEvent 转成 ACP 事件。
14+
4. 完整的涋出测试:模拟 stdio JSON-RPC 客户端,验证 newTask → 事件流 → done 。
15+
16+
**不做**:
17+
- 不动 OpenCode serve HTTP API(属 P5)。
18+
- 不改 MCP server(已实现 tools/list + tools/call)。
19+
- 不实现 ACP 的 `session/prompt`(别名越界限混乱,本期只 newTask)。
20+
21+
## 1. 当前状态(缺口分析)
22+
23+
- `internal/acp/acp.go` 实现了 `initialize`/`session/start`/`session/stop`/`session/list`。
24+
- 缺 `session/newTask`:ACP v1 核心方法,外部客户端无法驱动编排引擎。
25+
- Server 未接 Director,无法把任务交给编排。
26+
- 现有测试只覆盖 initialize/start/stop/list 入口,未覆盖 newTask 事件流。
27+
28+
## 2. 实施步骤
29+
30+
| # | 任务 | 产出物 | 估时 | 关键路径 |
31+
|---|---|---|---|---|
32+
| P6.1 | `session/newTask` method + task ID 生成 | `internal/acp/acp.go` | 30 min | ✓ |
33+
| P6.2 | `notifications/session/update` 推送流式事件 | `internal/acp/acp.go` | 40 min | ✓ |
34+
| P6.3 | ACP server 接 Director(`NewWithDirector`) | `internal/acp/acp.go` + `cmd/aicodingagentteam/main.go` | 30 min | ✓ |
35+
| P6.4 | 涋出测试:模拟 stdio 客户端验证 newTask → 事件流 | `internal/acp/acp_test.go` | 40 min | ✓ |
36+
| P6.5 | ADR-0020 + 计划 | `docs/adr/ADR-0020-*.md` | 15 min | ✓ |
37+
| P6.6 | CHANGELOG + v0.7.0 tag | `CHANGELOG.md` + git tag | 10 min | 末 |
38+
39+
总估时 ~2.5 小时。
40+
41+
## 3. 验收标准
42+
43+
- [ ] `session/newTask` method 返回 task ID
44+
- [ ] `notifications/session/update` 推送 start/message/tool_call/done/error
45+
- [ ] ACP server 能接 Director(`NewWithDirector`)
46+
- [ ] `TestACP_SessionNewTask_StreamsEvents`:模拟 stdio 客户端,验证 newTask → 事件流 → done
47+
- [ ] 现有 ACP initialize/start/stop/list 测试不破坏
48+
- [ ] `go build ./...` + `go test ./...` 全绿
49+
- [ ] 无 umadev/umacloud/goder.ai 字样
50+
51+
## 4. 不在范围
52+
53+
- OpenCode serve HTTP API -> P5
54+
- MCP server(已实现)
55+
- `session/prompt`(本期不做,避免越界混乱)
56+
- A2A HTTP server(已实现)
57+
58+
## 5. 风险与罓解
59+
60+
| 风险 | 罓解 |
61+
|---|---|
62+
| JSON-RPC notification 无 ID,客户端不易关联 | 事件含 sessionId + taskId 字段 |
63+
| Director.Handle 同步阻塞,事件流会等完才发 | 用 goroutine 调 Handle,事件通 channel 推送 |
64+
| stdio 写入与响应交织 | 响应用 mutex 保护,notification 单独线写 |
65+
66+
## 6. 文件清单
67+
68+
| 操作 | 路径 |
69+
|---|---|
70+
| 修改 | `internal/acp/acp.go` |
71+
| 修改 | `internal/acp/acp_test.go` |
72+
| 修改 | `cmd/aicodingagentteam/main.go`(acp 命令接 Director) |
73+
| 新建 | `docs/adr/ADR-0020-p6-acp-session-newtask.md` |
74+
| 修改 | `CHANGELOG.md` + tag v0.7.0 |

‎internal/acp/acp.go‎

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,63 @@ import (
1010
"io"
1111
"os"
1212
"sync"
13+
"sync/atomic"
14+
15+
`github.com/agentcodinglab/aicodingagentteam/internal/types`
1316
)
1417

1518
// Session represents an ACP agent session.
1619
type Session struct {
1720
ID string `json:"id"`
1821
Status string `json:"status"` // active / paused / stopped
22+
mu sync.Mutex
23+
tasks map[string]*Task // active task IDs for this session
24+
}
25+
26+
// Task is a single newTask execution within a session (ADR-0020).
27+
type Task struct {
28+
ID string `json:"taskId"`
29+
SessionID string `json:"sessionId"`
30+
Status string `json:"status"` // pending / running / done / error
31+
Events []TaskEvent `json:"events"`
32+
}
33+
34+
// TaskEvent is a single streamed event for a newTask.
35+
type TaskEvent struct {
36+
Type string `json:"type"` // start / message / tool_call / done / error
37+
Content string `json:"content"`
38+
}
39+
40+
// DirectorLike is the minimal contract the ACP server needs from the coordinator.
41+
// It lets us dispatch user requests without an import cycle.
42+
type DirectorLike interface {
43+
Handle(ctx context.Context, req types.UserRequest) (*types.Delivery, error)
1944
}
2045

2146
// Server handles ACP session lifecycle for standard agent clients.
2247
type Server struct {
23-
mu sync.Mutex
24-
sessions map[string]*Session
48+
mu sync.Mutex
49+
sessions map[string]*Session
50+
director DirectorLike // optional: when set, session/newTask dispatches to it
51+
taskCounter atomic.Int64
52+
notifier func(method string, params interface{}) // optional: write notifications to client (e.g. notifications/session/update)
2553
}
2654

27-
// New creates an ACP Server.
55+
// New creates an ACP Server without a director (lifecycle-only).
2856
func New() *Server {
2957
return &Server{sessions: make(map[string]*Session)}
3058
}
3159

60+
// NewWithDirector creates an ACP Server backed by a DirectorLike (ADR-0020).
61+
// notifier is invoked for each streamed event; it must be safe to call from any goroutine.
62+
func NewWithDirector(d DirectorLike, notifier func(method string, params interface{})) *Server {
63+
return &Server{
64+
sessions: make(map[string]*Session),
65+
director: d,
66+
notifier: notifier,
67+
}
68+
}
69+
3270
// jsonRPCRequest is a JSON-RPC 2.0 request envelope.
3371
type jsonRPCRequest struct {
3472
JSONRPC string `json:"jsonrpc"`
@@ -121,6 +159,8 @@ func (s *Server) handleMethod(ctx context.Context, req jsonRPCRequest) jsonRPCRe
121159
return s.handleSessionStop(ctx, req)
122160
case "session/list":
123161
return s.handleSessionList(ctx, req)
162+
case "session/newTask":
163+
return s.handleSessionNewTask(ctx, req)
124164
default:
125165
return jsonRPCResponse{
126166
JSONRPC: "2.0",
@@ -191,6 +231,102 @@ func (s *Server) handleSessionList(ctx context.Context, req jsonRPCRequest) json
191231
}
192232
}
193233

234+
235+
// sessionNewTaskParams holds arguments for session/newTask (ADR-0020).
236+
type sessionNewTaskParams struct {
237+
SessionID string `json:"sessionId"`
238+
AgentID string `json:"agentId"`
239+
Prompt string `json:"prompt"`
240+
}
241+
242+
// sessionUpdateParams is the payload of notifications/session/update events.
243+
type sessionUpdateParams struct {
244+
SessionID string `json:"sessionId"`
245+
TaskID string `json:"taskId"`
246+
Event TaskEvent `json:"event"`
247+
}
248+
249+
// handleSessionNewTask creates a new task in a session and dispatches the
250+
// request to the wired Director in a goroutine. Events are streamed via the
251+
// notifier (if set) as JSON-RPC notifications/session/update.
252+
func (s *Server) handleSessionNewTask(ctx context.Context, req jsonRPCRequest) jsonRPCResponse {
253+
var params sessionNewTaskParams
254+
if err := json.Unmarshal(req.Params, &params); err != nil {
255+
return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCErr{Code: -32602, Message: "invalid params"}}
256+
}
257+
if s.director == nil {
258+
return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCErr{Code: -32000, Message: "session/newTask requires Director"}}
259+
}
260+
s.mu.Lock()
261+
sess, ok := s.sessions[params.SessionID]
262+
if !ok {
263+
s.mu.Unlock()
264+
return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCErr{Code: -32001, Message: "session not found: " + params.SessionID}}
265+
}
266+
taskID := fmt.Sprintf("task-%d", s.taskCounter.Add(1))
267+
task := &Task{ID: taskID, SessionID: params.SessionID, Status: "pending"}
268+
if sess.tasks == nil {
269+
sess.tasks = make(map[string]*Task)
270+
}
271+
sess.tasks[taskID] = task
272+
s.mu.Unlock()
273+
274+
go s.dispatchTaskAsync(ctx, sess, task, params)
275+
276+
return jsonRPCResponse{
277+
JSONRPC: "2.0",
278+
ID: req.ID,
279+
Result: map[string]interface{}{
280+
"taskId": taskID,
281+
"status": "pending",
282+
},
283+
}
284+
}
285+
286+
// dispatchTaskAsync runs the task and emits streamed events.
287+
func (s *Server) dispatchTaskAsync(ctx context.Context, sess *Session, task *Task, params sessionNewTaskParams) {
288+
s.emitUpdate(sess, task, "start", params.Prompt)
289+
290+
delivery, err := s.director.Handle(ctx, types.UserRequest{
291+
Message: params.Prompt,
292+
Backend: params.AgentID,
293+
})
294+
if err != nil {
295+
task.Status = "error"
296+
s.emitUpdate(sess, task, "error", err.Error())
297+
return
298+
}
299+
300+
s.emitUpdate(sess, task, "message", fmt.Sprintf("planID=%s score=%d passed=%v", delivery.PlanID, delivery.Score, delivery.Passed))
301+
for _, a := range delivery.Artifacts {
302+
s.emitUpdate(sess, task, "tool_call", a)
303+
}
304+
if delivery.Passed {
305+
task.Status = "done"
306+
s.emitUpdate(sess, task, "done", "ok")
307+
} else {
308+
task.Status = "error"
309+
s.emitUpdate(sess, task, "error", "delivery did not pass")
310+
}
311+
}
312+
313+
// emitUpdate records the event on the task and (if wired) pushes a JSON-RPC
314+
// notifications/session/update to the client.
315+
func (s *Server) emitUpdate(sess *Session, task *Task, eventType, content string) {
316+
ev := TaskEvent{Type: eventType, Content: content}
317+
sess.mu.Lock()
318+
task.Events = append(task.Events, ev)
319+
sess.mu.Unlock()
320+
if s.notifier != nil {
321+
s.notifier("notifications/session/update", sessionUpdateParams{
322+
SessionID: sess.ID,
323+
TaskID: task.ID,
324+
Event: ev,
325+
})
326+
}
327+
}
328+
329+
194330
// Serve starts the ACP server over stdio JSON-RPC.
195331
func (s *Server) Serve(ctx context.Context) error {
196332
return s.serveReader(ctx, os.Stdin, os.Stdout)

0 commit comments

Comments
 (0)