diff --git a/internal/acp/adapter.go b/internal/acp/adapter.go index c0adef37..04ef6b56 100644 --- a/internal/acp/adapter.go +++ b/internal/acp/adapter.go @@ -44,7 +44,18 @@ func (h *Handler) handlePrompt(req *RPCRequest) *RPCErrorResponse { // the order prompts acquire the session's promptMu. ctx, cancel := context.WithCancel(context.Background()) run := &promptRun{cancel: cancel} + // Re-resolve the session under the lock rather than trusting the earlier + // lookup: between that lookup and this registration, session/delete or LRU + // eviction may have removed the session (both under h.mu). Registering into + // a stale context and running the turn anyway would execute on a closed + // runtime and — for delete — resurrect the .jsonl the delete just removed. h.mu.Lock() + sctx, ok = h.sessions[params.SessionID] + if !ok { + h.mu.Unlock() + cancel() + return NewErrorResponse(req.ID, ErrCodeInvalidParams, fmt.Sprintf("session not found: %s", params.SessionID)) + } if sctx.runs == nil { sctx.runs = make(map[*promptRun]struct{}) } diff --git a/internal/acp/handler.go b/internal/acp/handler.go index c8b70f83..264bcc1e 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -294,6 +294,8 @@ func (h *Handler) handleRequest(req *RPCRequest) { errResp = h.handleSessionLoad(req) case MethodSessionList: errResp = h.handleSessionList(req) + case MethodSessionDelete: + errResp = h.handleSessionDelete(req) case MethodSessionSetMode: errResp = h.handleSetMode(req) case MethodSessionCancel: @@ -334,7 +336,8 @@ func (h *Handler) handleInitialize(req *RPCRequest) *RPCErrorResponse { }, MCPCapabilities: &MCPCapabilities{HTTP: false, SSE: false}, SessionCapabilities: &SessionCapabilities{ - List: &SessionListCapabilities{}, + List: &SessionListCapabilities{}, + Delete: &SessionDeleteCapabilities{}, }, }, AgentInfo: &Implementation{Name: "whale", Title: "Whale", Version: "0.1.0"}, @@ -569,6 +572,99 @@ func (h *Handler) handleSessionList(req *RPCRequest) *RPCErrorResponse { return nil } +// handleSessionDelete implements the ACP session/delete method: it removes a +// persisted session so it disappears from session/list and can no longer be +// loaded. Delete is idempotent — an unknown session deletes successfully +// (Zed refreshes its list regardless), matching the ACP schema where the +// client owns list refresh. +// +// The one refused case is a session with an in-flight prompt. Every writer +// that appends to or rewrites the session's .jsonl (turn-message append, +// auto-compaction's tmp+rename) runs only while a turn is active — the same +// invariant eviction relies on. Without this guard, deleting the file while a +// prompt is mid-turn would let the prompt recreate it (file resurrection) and +// the session would reappear on the next list refresh. +func (h *Handler) handleSessionDelete(req *RPCRequest) *RPCErrorResponse { + var params DeleteSessionRequest + // Params are required here (unlike session/list): a delete for no session + // is meaningless, and a client that omits them sent an invalid request. + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return NewErrorResponse(req.ID, ErrCodeInvalidParams, fmt.Sprintf("invalid params: %v", err)) + } + id := strings.TrimSpace(params.SessionID) + if id == "" || !isSafeSessionID(id) { + return NewErrorResponse(req.ID, ErrCodeInvalidParams, fmt.Sprintf("invalid sessionId: %q", params.SessionID)) + } + sessionsDir := h.metaDir + if sessionsDir == "" { + return NewErrorResponse(req.ID, ErrCodeInternal, "session store not configured") + } + + // Live-session handling under h.mu: refuse in-flight prompts; otherwise + // drop the session from the map and close its runtime off the request path + // (an MCP stdio close can take ~5s — mirrors eviction). + var closeFn func() + h.mu.Lock() + if sctx, ok := h.sessions[id]; ok { + if len(sctx.runs) > 0 { + h.mu.Unlock() + return NewErrorResponse(req.ID, ErrCodeInternal, "session has an in-flight prompt; cannot delete") + } + delete(h.sessions, id) + if sctx.runtime != nil { + closeFn = sctx.runtime.Close + } + } + h.mu.Unlock() + if closeFn != nil { + go closeFn() + } + + if err := removeSessionFiles(sessionsDir, id); err != nil { + Logger.Printf("failed to delete session %s: %v", id, err) + return NewErrorResponse(req.ID, ErrCodeInternal, fmt.Sprintf("failed to delete session files: %v", err)) + } + Logger.Printf("session deleted: %s", id) + h.transport.SendResponse(NewSuccessResponse(req.ID, DeleteSessionResponse{})) + return nil +} + +// removeSessionFiles deletes a session's persisted artifacts. The primary +// .jsonl is fatal on failure — the session's history must not silently +// survive — while the sidecars are best-effort: they cover both the ACP +// writers (meta sidecar) and the shared store convention (approvals file, +// telemetry event logs, stale .jsonl.tmp left by an interrupted rewrite), +// and a leftover sidecar alone must not fail a successful delete. +func removeSessionFiles(sessionsDir, id string) error { + name := core.SanitizeSessionID(id) + primary := filepath.Join(sessionsDir, name+".jsonl") + if err := os.Remove(primary); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove session file: %w", err) + } + for _, suffix := range []string{ + ".meta.json", + // Session-scoped sidecars persisted by the state helpers under + // internal/session: mode (mode_state.go), todos (todo_state.go), + // pending user-input questions (user_input_state.go), and goal + // (goal_state.go). Deleting the thread must not leave these behind — + // they would resurrect mode/todos/objective on a future load. + ".state.json", + ".todo.json", + ".user_input.json", + ".goal.json", + core.ApprovalEventsSuffix, + core.ToolInputEventsSuffix, + ".approvals.json", + ".jsonl.tmp", + } { + path := filepath.Join(sessionsDir, name+suffix) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + Logger.Printf("failed to remove %s for session %s: %v", suffix, id, err) + } + } + return nil +} + var acpToWhaleMode = map[string]session.Mode{ "code": session.ModeAgent, "ask": session.ModeAsk, "architect": session.ModePlan, } diff --git a/internal/acp/handler_test.go b/internal/acp/handler_test.go index af3f5ac2..98a4095e 100644 --- a/internal/acp/handler_test.go +++ b/internal/acp/handler_test.go @@ -302,8 +302,8 @@ func TestInitializeAdvertisesSessionList(t *testing.T) { if sc == nil || sc.List == nil { t.Fatalf("expected sessionCapabilities.list to be advertised, got %+v", sc) } - if sc.Delete != nil { - t.Fatalf("session/delete is not implemented and must not be advertised, got %+v", sc.Delete) + if sc.Delete == nil { + t.Fatalf("expected sessionCapabilities.delete to be advertised, got %+v", sc) } } @@ -1033,3 +1033,469 @@ func TestTranslateEventContextCompactedDroppedWithoutPanic(t *testing.T) { t.Fatalf("ContextCompacted with info translated to %+v, want nil", u) } } + +// deleteSessionRaw issues a session/delete request over a fresh handler bound +// to the given store/sessions dirs, returning the parsed RPC error object +// (nil on success). Params is used verbatim; empty means the params field is +// omitted entirely. +func deleteSessionRaw(t *testing.T, storeDir, sessionsDir, defaultCwd, params string) (map[string]any, error) { + t.Helper() + msgStore, err := store.NewJSONLStore(storeDir) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, defaultCwd) + h.SetSessionsDir(sessionsDir) + req := &RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 1}, + Method: MethodSessionDelete, + } + if params != "" { + req.Params = json.RawMessage(params) + } + h.handleRequest(req) + var resp struct { + Result DeleteSessionResponse `json:"result"` + Err map[string]any `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + return resp.Err, nil +} + +func assertSessionArtifactsGone(t *testing.T, dir, id string) { + t.Helper() + for _, suffix := range []string{".jsonl", ".meta.json", ".state.json", ".todo.json", ".user_input.json", ".goal.json", ".approval_events.jsonl", ".tool_input_events.jsonl", ".approvals.json", ".jsonl.tmp"} { + if _, err := os.Stat(filepath.Join(dir, core.SanitizeSessionID(id)+suffix)); !os.IsNotExist(err) { + t.Fatalf("expected %s%s removed, stat err=%v", id, suffix, err) + } + } +} + +// TestSessionDeleteRemovesPersistedSession: happy path — the primary .jsonl +// and every sidecar (meta, telemetry events, approvals, stale rewrite tmp) +// are removed, and session/list no longer lists the session. +func TestSessionDeleteRemovesPersistedSession(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "sess-1", "/work", "hello", time.Now()) + for _, suffix := range []string{".state.json", ".todo.json", ".user_input.json", ".goal.json", ".approval_events.jsonl", ".tool_input_events.jsonl", ".approvals.json", ".jsonl.tmp"} { + if err := os.WriteFile(dir+"/sess-1"+suffix, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + rpcErr, err := deleteSessionRaw(t, dir, dir, "/work", `{"sessionId":"sess-1"}`) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr != nil { + t.Fatalf("delete errored: %v", rpcErr) + } + assertSessionArtifactsGone(t, dir, "sess-1") + + resp, err := listSessionsFromDir(t, dir, "/work", `{}`) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if got := listSessionIDs(resp); len(got) != 0 { + t.Fatalf("deleted session still listed: %v", got) + } +} + +// TestSessionDeleteUnknownIDIsIdempotentSuccess: deleting a session that has +// no files and no live context succeeds (delete is naturally idempotent; the +// client refreshes its list unconditionally) and removes nothing else. +func TestSessionDeleteUnknownIDIsIdempotentSuccess(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "keep", "/work", "keep me", time.Now()) + + rpcErr, err := deleteSessionRaw(t, dir, dir, "/work", `{"sessionId":"nope"}`) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr != nil { + t.Fatalf("unknown-session delete must succeed, got %v", rpcErr) + } + if _, err := os.Stat(dir + "/keep.jsonl"); err != nil { + t.Fatalf("unrelated session removed: %v", err) + } +} + +// TestSessionDeleteInvalidParams: missing/malformed params, empty ids, and +// path-unsafe ids are rejected with ErrCodeInvalidParams before any file +// access; the session file must be untouched. +func TestSessionDeleteInvalidParams(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "sess-1", "/work", "hello", time.Now()) + + cases := []struct{ name, params string }{ + {"omitted params", ""}, + {"malformed params", `{`}, + {"empty id", `{"sessionId":""}`}, + {"blank id", `{"sessionId":" "}`}, + {"dot", `{"sessionId":"."}`}, + {"dotdot", `{"sessionId":".."}`}, + {"traversal", `{"sessionId":"../x"}`}, + {"slash", `{"sessionId":"a/b"}`}, + {"backslash", `{"sessionId":"a\\b"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rpcErr, err := deleteSessionRaw(t, dir, dir, "/work", tc.params) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr == nil { + t.Fatalf("expected error for %q", tc.params) + } + if code, _ := rpcErr["code"].(float64); int(code) != ErrCodeInvalidParams { + t.Fatalf("code = %v, want %d", rpcErr["code"], ErrCodeInvalidParams) + } + if _, err := os.Stat(dir + "/sess-1.jsonl"); err != nil { + t.Fatalf("session file touched by invalid delete: %v", err) + } + }) + } +} + +// TestSessionDeleteMetaDirUnset: without a configured sessions directory the +// handler cannot remove artifacts and must report an internal error rather +// than a false success. +func TestSessionDeleteMetaDirUnset(t *testing.T) { + msgStore, err := store.NewJSONLStore(t.TempDir()) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, "/work") + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 1}, + Method: MethodSessionDelete, + Params: json.RawMessage(`{"sessionId":"acp-x"}`), + }) + var resp struct { + Err struct{ Code int } `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil { + t.Fatalf("parse response: %v", err) + } + if resp.Err.Code != ErrCodeInternal { + t.Fatalf("code = %d, want %d", resp.Err.Code, ErrCodeInternal) + } +} + +// TestSessionDeleteLiveIdleSession: a live-but-idle session is removed from +// the handler map, its runtime Close hook fires off the request path, and its +// persisted artifacts are removed. +func TestSessionDeleteLiveIdleSession(t *testing.T) { + dir := t.TempDir() + closed := make(chan struct{}) + var once sync.Once + rec := &factoryRecorder{closeFn: func() { once.Do(func() { close(closed) }) }} + msgStore, err := store.NewJSONLStore(dir) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, "/work") + h.SetSessionsDir(dir) + h.SetRuntimeFactory(func(acpSessionID, cwd string, mcps []MCPServer) (*SessionRuntime, error) { + return &SessionRuntime{Close: rec.closeFn}, nil + }) + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 1}, + Method: MethodSessionNew, + Params: json.RawMessage(`{"cwd":"/work"}`), + }) + var id string + h.mu.Lock() + for k := range h.sessions { + id = k + } + h.mu.Unlock() + if id == "" { + t.Fatal("no session created") + } + if err := os.WriteFile(dir+"/"+id+".jsonl", []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + buf.Reset() + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 2}, + Method: MethodSessionDelete, + Params: json.RawMessage(`{"sessionId":"` + id + `"}`), + }) + var resp struct { + Result DeleteSessionResponse `json:"result"` + Err map[string]any `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil { + t.Fatalf("parse delete response: %v", err) + } + if resp.Err != nil { + t.Fatalf("delete live idle session errored: %v", resp.Err) + } + h.mu.Lock() + _, stillLive := h.sessions[id] + h.mu.Unlock() + if stillLive { + t.Fatal("deleted session still in handler map") + } + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("runtime Close not invoked for deleted idle session") + } + if _, err := os.Stat(dir + "/" + id + ".jsonl"); !os.IsNotExist(err) { + t.Fatalf("live session artifact not removed: %v", err) + } +} + +// TestSessionDeleteRefusesInFlight: a session with an active prompt cannot be +// deleted — every .jsonl writer (turn append, compaction rewrite) runs during +// a turn, so deleting mid-turn would let the prompt resurrect the file. The +// session stays live and its artifacts stay intact. +func TestSessionDeleteRefusesInFlight(t *testing.T) { + dir := t.TempDir() + closed := 0 + rec := &factoryRecorder{closeFn: func() { closed++ }} + msgStore, err := store.NewJSONLStore(dir) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, "/work") + h.SetSessionsDir(dir) + h.SetRuntimeFactory(func(acpSessionID, cwd string, mcps []MCPServer) (*SessionRuntime, error) { + return &SessionRuntime{Close: rec.closeFn}, nil + }) + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 1}, + Method: MethodSessionNew, + Params: json.RawMessage(`{"cwd":"/work"}`), + }) + var id string + h.mu.Lock() + for k := range h.sessions { + id = k + } + h.sessions[id].runs = map[*promptRun]struct{}{{}: {}} + h.mu.Unlock() + if err := os.WriteFile(dir+"/"+id+".jsonl", []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + buf.Reset() + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 2}, + Method: MethodSessionDelete, + Params: json.RawMessage(`{"sessionId":"` + id + `"}`), + }) + var resp struct { + Err struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil { + t.Fatalf("parse delete response: %v", err) + } + if resp.Err.Code != ErrCodeInternal { + t.Fatalf("code = %d, want %d (in-flight refusal)", resp.Err.Code, ErrCodeInternal) + } + h.mu.Lock() + _, stillLive := h.sessions[id] + h.mu.Unlock() + if !stillLive { + t.Fatal("in-flight session removed from map despite refusal") + } + if _, err := os.Stat(dir + "/" + id + ".jsonl"); err != nil { + t.Fatalf("in-flight session artifact removed: %v", err) + } + if closed != 0 { + t.Fatalf("runtime Close invoked on refused delete (%d calls)", closed) + } +} + +// TestSessionDeleteThenLoadReplaysNothing pins the post-delete contract: +// session/load finds no messages and replays zero updates (load is lenient — +// it logs and continues with an empty history rather than erroring). +func TestSessionDeleteThenLoadReplaysNothing(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "gone", "/work", "hello", time.Now()) + msgStore, err := store.NewJSONLStore(dir) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, "/work") + h.SetSessionsDir(dir) + h.SetRuntimeFactory(func(acpSessionID, cwd string, mcps []MCPServer) (*SessionRuntime, error) { + return &SessionRuntime{}, nil + }) + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 1}, + Method: MethodSessionDelete, + Params: json.RawMessage(`{"sessionId":"gone"}`), + }) + + buf.Reset() + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 2}, + Method: MethodSessionLoad, + Params: json.RawMessage(`{"sessionId":"gone","cwd":"/work"}`), + }) + for _, line := range strings.Split(buf.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg struct { + Method string `json:"method"` + } + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("bad response line: %v", err) + } + if msg.Method == MethodSessionUpdate { + t.Fatalf("load after delete replayed a message: %s", line) + } + } +} + +// TestSessionDeleteJSONLRemovalFailureIsFatal: when the primary .jsonl cannot +// be removed (here, it is a directory), delete must fail with an internal +// error rather than report success — the session's history must not silently +// survive a successful delete. +func TestSessionDeleteJSONLRemovalFailureIsFatal(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(dir+"/blocked.jsonl", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir+"/blocked.jsonl/child", []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + rpcErr, err := deleteSessionRaw(t, dir, dir, "/work", `{"sessionId":"blocked"}`) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr == nil { + t.Fatal("delete succeeded despite failed .jsonl removal") + } + if code, _ := rpcErr["code"].(float64); int(code) != ErrCodeInternal { + t.Fatalf("code = %v, want %d", rpcErr["code"], ErrCodeInternal) + } + if st, err := os.Stat(dir + "/blocked.jsonl"); err != nil || !st.IsDir() { + t.Fatalf("blocking path not preserved: %v", err) + } +} + +// TestSessionDeleteConcurrentWithList hammers session/delete (idempotent) and +// session/list from many goroutines over one shared handler. Run under -race: +// every response must be well-formed, no session may error spuriously, and the +// victim's artifacts must be gone at the end. +func TestSessionDeleteConcurrentWithList(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "victim", "/work", "hello", time.Now()) + msgStore, err := store.NewJSONLStore(dir) + if err != nil { + t.Fatalf("create store: %v", err) + } + var buf bytes.Buffer + h := NewHandler(NewTransportWithIO(&buf, &buf, &buf), msgStore, "/work") + h.SetSessionsDir(dir) + + const deleters = 8 + var wg sync.WaitGroup + for i := 0; i < deleters; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 25; j++ { + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: n*1000 + j}, + Method: MethodSessionDelete, + Params: json.RawMessage(`{"sessionId":"victim"}`), + }) + } + }(i) + } + for k := 0; k < 25; k++ { + h.handleRequest(&RPCRequest{ + JSONRPC: "2.0", + ID: &RequestID{Value: 10000 + k}, + Method: MethodSessionList, + Params: json.RawMessage(`{}`), + }) + } + wg.Wait() + + deleteErrors := 0 + for _, line := range strings.Split(buf.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg struct { + ID float64 `json:"id"` + Err map[string]any `json:"error"` + } + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("bad response line: %v", err) + } + if msg.ID < 10000 && msg.Err != nil { + deleteErrors++ + } + } + if deleteErrors != 0 { + t.Fatalf("%d session/delete calls errored (delete must be idempotent)", deleteErrors) + } + assertSessionArtifactsGone(t, dir, "victim") +} + +// TestSessionDeleteMetaDirIsFile: when the sessions directory path is a +// regular file, removing artifacts under it fails with ENOTDIR — delete must +// report an internal error instead of a false success. +func TestSessionDeleteMetaDirIsFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "notadir") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + rpcErr, err := deleteSessionRaw(t, dir, file, "/work", `{"sessionId":"sess-1"}`) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr == nil { + t.Fatal("delete succeeded with a file as sessions dir") + } + if code, _ := rpcErr["code"].(float64); int(code) != ErrCodeInternal { + t.Fatalf("code = %v, want %d", rpcErr["code"], ErrCodeInternal) + } +} + +// TestSessionDeleteIgnoresMetaField: the ACP schema allows an optional _meta +// object on any request; it must not break delete. +func TestSessionDeleteIgnoresMetaField(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, dir, "sess-1", "/work", "hello", time.Now()) + rpcErr, err := deleteSessionRaw(t, dir, dir, "/work", `{"sessionId":"sess-1","_meta":{"why":"cleanup"}}`) + if err != nil { + t.Fatalf("delete: %v", err) + } + if rpcErr != nil { + t.Fatalf("delete with _meta errored: %v", rpcErr) + } + assertSessionArtifactsGone(t, dir, "sess-1") +} diff --git a/internal/acp/types.go b/internal/acp/types.go index 8655df80..5951f476 100644 --- a/internal/acp/types.go +++ b/internal/acp/types.go @@ -118,6 +118,7 @@ const ( MethodSessionNew = "session/new" MethodSessionLoad = "session/load" MethodSessionList = "session/list" + MethodSessionDelete = "session/delete" MethodSessionSetMode = "session/set_mode" MethodSessionSetConfigOpt = "session/set_config_option" MethodSessionPrompt = "session/prompt" @@ -202,8 +203,9 @@ type SessionCapabilities struct { // object means the agent supports listing sessions (see ACP session // management); clients such as Zed gate their session-history UI on it. List *SessionListCapabilities `json:"list,omitempty"` - // Delete advertises support for the session/delete method. Not implemented - // by whale-acp, so it is intentionally never advertised. + // Delete advertises support for the session/delete method. Supplying an + // empty object means the agent supports deleting sessions (see ACP session + // management); clients such as Zed gate the agent-panel Trash button on it. Delete *SessionDeleteCapabilities `json:"delete,omitempty"` // AdditionalDirectories advertises support for additionalDirectories on // session lifecycle requests. Not implemented. @@ -375,6 +377,25 @@ type SessionInfo struct { UpdatedAt string `json:"updatedAt,omitempty"` } +// --------------------------------------------------------------------------- +// Session / delete +// --------------------------------------------------------------------------- + +// DeleteSessionRequest deletes a persisted session so it no longer appears in +// session/list and can no longer be loaded. Per the ACP schema the sessionId +// is required; _meta is ignored. +type DeleteSessionRequest struct { + SessionID string `json:"sessionId"` + Meta map[string]any `json:"_meta,omitempty"` +} + +// DeleteSessionResponse is the empty success result of session/delete. The +// client refreshes its session list itself (e.g. Zed sends +// SessionListUpdate::Refresh), so no notification is returned. +type DeleteSessionResponse struct { + Meta map[string]any `json:"_meta,omitempty"` +} + // --------------------------------------------------------------------------- // Session / prompt // ---------------------------------------------------------------------------