Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
12f0779
feat(acp): add standard session list and resume
gnanam1990 Aug 16, 2026
17cde64
fix(acp): preserve notification wire order
gnanam1990 Aug 16, 2026
5a3ee23
fix(acp): bind sessions to persisted workspaces
gnanam1990 Aug 16, 2026
c41104d
fix(acp): compare workspaces by filesystem identity, not by spelling
gnanam1990 Aug 17, 2026
7b906fc
fix(acp): test the alias guard where the bug lives, and stop listing …
gnanam1990 Aug 18, 2026
76e2e28
fix(acp): resolve every listed workspace, not only when a filter was …
gnanam1990 Aug 19, 2026
d3406f4
test(acp): assert the relative entry is retained, not merely absolute
gnanam1990 Aug 19, 2026
9e6b732
fix(acp): a relative persisted workspace is omitted, not rebased
gnanam1990 Aug 20, 2026
3447fd7
test(acp): the resume guard is asserted through both activating methods
gnanam1990 Aug 20, 2026
6267297
fix(acp): activation requires an absolute cwd in the REQUEST, not jus…
gnanam1990 Aug 24, 2026
07bb582
fix(acp): resume the conversation a session became, and fail closed w…
gnanam1990 Aug 25, 2026
386aa25
fix(acp): persist tool activity for session replay
gnanam1990 Aug 28, 2026
718ffc8
test(acp): bound replay notification waits
gnanam1990 Sep 2, 2026
c0bf29a
fix(acp): preserve session restore invariants
gnanam1990 Sep 7, 2026
fb8f1e0
fix(acp): restore session lifecycle boundaries
gnanam1990 Sep 9, 2026
9515f38
fix(acp): preserve replay and restore identity
gnanam1990 Sep 9, 2026
227fac9
fix(acp): commit a completed turn as one store batch
gnanam1990 Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/HOW_ZERO_WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
resume/history; ACP persists conversational messages and completed tool-call
starts/results for load replay. Live streaming details and permission
exchanges remain client notifications rather than durable history.

Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
Expand Down
598 changes: 545 additions & 53 deletions internal/acp/agent.go

Large diffs are not rendered by default.

1,697 changes: 1,684 additions & 13 deletions internal/acp/agent_test.go

Large diffs are not rendered by default.

53 changes: 43 additions & 10 deletions internal/acp/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ type Conn struct {

handlers map[string]HandlerFunc
notifiers map[string]NotifyFunc
// Notifications for one method are an ordered stream: session/update in
// particular is stateful, so dispatching every frame in an unrelated
// goroutine can make a later chunk overtake an earlier one. Different
// methods retain independent tails, keeping session/cancel responsive while
// an update handler is busy.
notifyMu sync.Mutex
notifyTails map[string]chan struct{}

mu sync.Mutex
nextID int64
Expand All @@ -106,11 +113,12 @@ type Conn struct {
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
pending: make(map[int64]chan rpcMessage),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
notifyTails: make(map[string]chan struct{}),
pending: make(map[int64]chan rpcMessage),
}
}

Expand Down Expand Up @@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}(msg)
case msg.isNotify():
if fn := c.notifiers[msg.Method]; fn != nil {
c.wg.Add(1)
go func(m rpcMessage) {
defer c.wg.Done()
fn(ctx, m.Params)
}(msg)
c.dispatchNotification(ctx, msg, fn)
}
default:
// Malformed frame; reply only if we can identify a request id.
Expand All @@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}
}

// dispatchNotification preserves wire order within one method without making
// unrelated notification methods wait for each other. The read loop installs
// each tail before starting its goroutine, so goroutine scheduling cannot
// reorder the chain it observes.
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
c.notifyMu.Lock()
previous := c.notifyTails[msg.Method]
done := make(chan struct{})
c.notifyTails[msg.Method] = done
c.notifyMu.Unlock()

c.wg.Add(1)
go func() {
defer c.wg.Done()
defer func() {
close(done)
c.notifyMu.Lock()
if c.notifyTails[msg.Method] == done {
delete(c.notifyTails, msg.Method)
}
c.notifyMu.Unlock()
}()
if previous != nil {
<-previous
}
fn(ctx, msg.Params)
}()
}

func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
fn := c.handlers[msg.Method]
if fn == nil {
Expand Down
80 changes: 80 additions & 0 deletions internal/acp/jsonrpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
}
}

func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
a, b, stop := connPair(t)
defer stop()

firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
delivered := make(chan int, 2)
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
var in struct{ Sequence int }
_ = json.Unmarshal(params, &in)
if in.Sequence == 1 {
close(firstStarted)
<-releaseFirst
}
delivered <- in.Sequence
})

if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
t.Fatalf("first notify: %v", err)
}
select {
case <-firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("first notification did not start")
}
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
t.Fatalf("second notify: %v", err)
}
select {
case sequence := <-delivered:
t.Fatalf("notification %d overtook the blocked first notification", sequence)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
for want := 1; want <= 2; want++ {
select {
case got := <-delivered:
if got != want {
t.Fatalf("notification order = ...,%d; want %d", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("notification %d was not delivered", want)
}
}
}

func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
a, b, stop := connPair(t)
defer stop()

updateStarted := make(chan struct{})
releaseUpdate := make(chan struct{})
cancelDelivered := make(chan struct{})
b.HandleNotify("update", func(context.Context, json.RawMessage) {
close(updateStarted)
<-releaseUpdate
})
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
close(cancelDelivered)
})

if err := a.Notify("update", nil); err != nil {
t.Fatalf("update notify: %v", err)
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("update notification did not start")
}
if err := a.Notify("cancel", nil); err != nil {
t.Fatalf("cancel notify: %v", err)
}
select {
case <-cancelDelivered:
case <-time.After(2 * time.Second):
t.Fatal("different notification method was blocked behind update")
}
close(releaseUpdate)
}

func TestConnMethodNotFound(t *testing.T) {
a, _, stop := connPair(t)
defer stop()
Expand Down
8 changes: 8 additions & 0 deletions internal/acp/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ func agentMessageChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
}

func replayMessageChunk(role, messageID, text string) ContentChunk {
update := UpdateAgentMessageChunk
if role == "user" {
update = UpdateUserMessageChunk
}
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
}

func agentThoughtChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
}
Expand Down
59 changes: 54 additions & 5 deletions internal/acp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const (
MethodAuthenticate = "authenticate"
MethodSessionNew = "session/new"
MethodSessionLoad = "session/load"
MethodSessionList = "session/list"
MethodSessionResume = "session/resume"
MethodSessionPrompt = "session/prompt"
MethodSessionCancel = "session/cancel" // notification
MethodSessionUpdate = "session/update" // notification (agent -> client)
Expand Down Expand Up @@ -62,8 +64,16 @@ type PromptCapabilities struct {
}

type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
}

// Empty capability objects are presence flags in ACP v1. Pointers preserve
// the wire distinction between an advertised `{}` and an omitted capability.
type SessionCapabilities struct {
List *struct{} `json:"list,omitempty"`
Resume *struct{} `json:"resume,omitempty"`
}

type AuthMethod struct {
Expand Down Expand Up @@ -117,9 +127,14 @@ type McpServer struct {
}

type NewSessionParams struct {
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
// AdditionalDirectories is declared by the protocol and consumed nowhere in
// this repo yet. When it is wired up it must go through requestedWorkspace
// like Cwd does: these are client-supplied paths with no absoluteness rule of
// their own, which is the same shape as the resume-cwd hole that made
// {"sessionId":"known"} activate a session against this process's directory.
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
}

type NewSessionResult struct {
Expand All @@ -140,6 +155,39 @@ type LoadSessionResult struct {
Modes *SessionModeState `json:"modes,omitempty"`
}

// ListSessionsParams and the following types implement ACP v1 session/list.
// Transcript contents stay behind session/load; this method returns metadata
// only and leaves the optional pagination cursor opaque.
type ListSessionsParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}

type SessionInfoMeta struct {
ModelID string `json:"modelId,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}

type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta *SessionInfoMeta `json:"_meta,omitempty"`
}

type ListSessionsResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}

// session/resume takes session/load's wire shape, so its cwd is a plain string
// too and an omitted one decodes exactly like an empty one. Sharing the type is
// safe only because activatePersistedSession requires an absolute cwd from the
// request itself — nothing downstream may supply a default for either method.
type ResumeSessionParams = LoadSessionParams
type ResumeSessionResult = LoadSessionResult

// ---- prompt turn ----

type PromptParams struct {
Expand Down Expand Up @@ -174,6 +222,7 @@ type SessionNotification struct {
// ContentBlock under "content"; the variant is set via SessionUpdate.
type ContentChunk struct {
SessionUpdate string `json:"sessionUpdate"`
MessageID string `json:"messageId,omitempty"`
Content ContentBlock `json:"content"`
}

Expand Down
15 changes: 12 additions & 3 deletions internal/sessions/replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,20 @@ func CompactionPayloadFromPlan(summary string, plan CompactionPlan) (CompactionP
}

func (store *Store) ReadRehydratedEvents(sessionID string) ([]Event, error) {
events, err := store.ReadEvents(sessionID)
events, _, err := store.ReadRehydratedEventsWithPresence(sessionID)
return events, err
}

// ReadRehydratedEventsWithPresence carries the underlying event-log presence
// through compaction projection without changing ReadRehydratedEvents' existing
// empty-on-missing contract.
func (store *Store) ReadRehydratedEventsWithPresence(sessionID string) ([]Event, bool, error) {
events, present, err := store.ReadEventsWithPresence(sessionID)
if err != nil {
return nil, err
return nil, present, err
}
return RehydrateEvents(events)
rehydrated, err := RehydrateEvents(events)
return rehydrated, present, err
}

func (store *Store) ReadReplayEvents(sessionID string) ([]Event, error) {
Expand Down
19 changes: 14 additions & 5 deletions internal/sessions/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -813,15 +813,24 @@ func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, err
}

func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
events, _, err := store.ReadEventsWithPresence(sessionID)
return events, err
}

// ReadEventsWithPresence preserves the distinction between an intentionally
// empty event log and a missing one. Most readers accept both as empty history,
// but activation protocols that promise to restore previously populated
// context need the stronger signal.
func (store *Store) ReadEventsWithPresence(sessionID string) ([]Event, bool, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
return nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
data, err := os.ReadFile(store.eventsPath(sessionID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []Event{}, nil
return []Event{}, false, nil
}
return nil, fmt.Errorf("read zero session events: %w", err)
return nil, false, fmt.Errorf("read zero session events: %w", err)
}
// A genuine torn tail is an INCOMPLETE final write — a crash mid-append leaves
// the last line without its terminating newline. If the file ends with a
Expand Down Expand Up @@ -851,11 +860,11 @@ func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
if index == lastNonEmpty && tornTailPossible {
break
}
return nil, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
return nil, true, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
}
events = append(events, event)
}
return events, nil
return events, true, nil
}

func (store *Store) timestamp() string {
Expand Down
Loading