Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 deletions internal/agentproxy/opencode/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,17 @@ func (f *Facade) handleSessionCreate(w http.ResponseWriter, r *http.Request) {
f.writeJSON(w, f.sessionObject())
}

// handleSessionList answers GET /session: empty until the client has
// created/used the session, then a single-entry list. An empty list is the
// "fresh server" state the TUI expects on first attach (verified in the M0
// capture); returning the session unconditionally would make every attach
// look like a resume.
// handleSessionList answers GET /session: the bridged session appears when
// this proxy has used it (sessionCreated) or when the hosted session has
// replayable history — that's what lets `opencode attach --continue` and the
// session picker resume prior turns through a freshly started proxy. A truly
// fresh session lists empty, the "fresh server" state the TUI expects on
// first attach (verified in the M0 capture).
func (f *Facade) handleSessionList(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
created := f.sessionCreated
f.mu.Unlock()
if !created {
if !created && !f.hasHistory(r.Context()) {
f.writeJSON(w, []any{})
return
}
Expand Down Expand Up @@ -377,6 +378,8 @@ func (f *Facade) translateEvent(ev godo.HostedAgentEvent, ts *turnState, ew *eve

case godo.HostedAgentEventKindRunCompleted:
defer f.dropTurn(ev.RunID)
// The finished turn is durable history now; the cache predates it.
f.invalidateHistory()
// Finalize the text part with its full content before idling — the
// real server does (deltas stream, then the part's final state
// re-carries the whole text; plano's adapter must drop that as a
Expand All @@ -403,6 +406,7 @@ func (f *Facade) translateEvent(ev godo.HostedAgentEvent, ts *turnState, ew *eve

case godo.HostedAgentEventKindRunFailed:
defer f.dropTurn(ev.RunID)
f.invalidateHistory()
var payload struct {
Message string `json:"message"`
}
Expand Down
34 changes: 29 additions & 5 deletions internal/agentproxy/opencode/facade.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
package opencode

import (
"context"
"encoding/json"
"fmt"
"log"
Expand Down Expand Up @@ -72,11 +73,31 @@ type Facade struct {
// session, so the session list grows its single entry (see
// handleSessionList).
sessionCreated bool

// History cache (see history() in history.go). histMu guards only the
// fields — the slow replay_only fetch runs outside it so invalidation
// never blocks behind it; histFetching/histDone single-flight concurrent
// fetchers, and histGen detects an invalidation that overlapped a fetch.
histMu sync.Mutex
hist []historyMessage
histValid bool
histGen int
histFetching bool
histDone chan struct{}
}

// ServeHTTP implements http.Handler.
func (f *Facade) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.handlerOnce.Do(f.buildMux)
f.handlerOnce.Do(func() {
f.buildMux()
// Warm the history cache off the first request — the TUI's
// /global/health preflight lands well before the attach burst, so
// the slow replay (see history()) usually finishes before the burst
// asks for the session list or messages.
if f.Sessions != nil {
go func() { _, _ = f.history(context.Background()) }()
}
})
f.mux.ServeHTTP(w, r)
}

Expand Down Expand Up @@ -163,15 +184,18 @@ func (f *Facade) buildMux() {
mux.HandleFunc("GET /experimental/workspace", f.json([]any{}))
mux.HandleFunc("GET /experimental/workspace/status", f.json([]any{}))

// The bridged session: list/create/get plus the prompt bridge (M2).
// History (GET .../message) returns empty until M3 serves it from a
// replay_only stream pass.
// The bridged session: list/create/get plus the prompt bridge (M2) and
// history (M3, served from a replay_only stream pass). diff/todo are the
// two lookups the TUI fires after every turn (post-idle refresh) and on
// resume — real server returns empty arrays for a no-edits session.
mux.HandleFunc("GET /session", f.handleSessionList)
mux.HandleFunc("POST /session", f.handleSessionCreate)
mux.HandleFunc("GET /session/{id}", func(w http.ResponseWriter, r *http.Request) {
f.writeJSON(w, f.sessionObject())
})
mux.HandleFunc("GET /session/{id}/message", f.json([]any{}))
mux.HandleFunc("GET /session/{id}/message", f.handleMessageList)
mux.HandleFunc("GET /session/{id}/diff", f.json([]any{}))
mux.HandleFunc("GET /session/{id}/todo", f.json([]any{}))
mux.HandleFunc("POST /session/{id}/prompt_async", f.handlePromptAsync)
// The synchronous variant officially awaits the reply; bridging that
// faithfully would block a request goroutine for a whole turn. Current
Expand Down
20 changes: 5 additions & 15 deletions internal/agentproxy/opencode/facade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,18 @@ import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newTestFacade() *Facade {
return &Facade{SessionID: "sess-1", Dir: "/tmp/ws"}
}

// The attach burst captured from a real `opencode attach` (TestedVersion).
// Every route the TUI hits at startup must answer 200 with valid JSON —
// a 404 here is exactly the "new client version wants more" drift signal.
func TestAttachBurstRoutesAnswer(t *testing.T) {
srv := httptest.NewServer(newTestFacade())
defer srv.Close()
srv, _ := newBridgedFacade(t)

routes := []string{
"/global/health",
Expand Down Expand Up @@ -65,8 +59,7 @@ func TestAttachBurstRoutesAnswer(t *testing.T) {
}

func TestHealthReportsTestedVersion(t *testing.T) {
srv := httptest.NewServer(newTestFacade())
defer srv.Close()
srv, _ := newBridgedFacade(t)

resp, err := http.Get(srv.URL + "/global/health")
require.NoError(t, err)
Expand Down Expand Up @@ -131,8 +124,7 @@ func TestSecondEventStreamConsumerConflicts(t *testing.T) {
}

func TestShareIsRefused(t *testing.T) {
srv := httptest.NewServer(newTestFacade())
defer srv.Close()
srv, _ := newBridgedFacade(t)

resp, err := http.Post(srv.URL+"/session/sess-1/share", "application/json", nil)
require.NoError(t, err)
Expand All @@ -141,8 +133,7 @@ func TestShareIsRefused(t *testing.T) {
}

func TestUnknownRouteIs404(t *testing.T) {
srv := httptest.NewServer(newTestFacade())
defer srv.Close()
srv, _ := newBridgedFacade(t)

resp, err := http.Get(srv.URL + "/no/such/route")
require.NoError(t, err)
Expand All @@ -154,8 +145,7 @@ func TestUnknownRouteIs404(t *testing.T) {
// /config/providers points at the one provider/model pair every other
// catalog route advertises.
func TestSyntheticCatalogIsConsistent(t *testing.T) {
srv := httptest.NewServer(newTestFacade())
defer srv.Close()
srv, _ := newBridgedFacade(t)

resp, err := http.Get(srv.URL + "/config/providers")
require.NoError(t, err)
Expand Down
Loading